ci: run the unit suite in parallel and keep it off the network

Test servers shut down without the 0.5s poll wait, unit tests refuse non-loopback lookups, and three load-sensitive tests are steadied. Pushes to dev test Python 3.11 only; the docs job builds with uv.
This commit is contained in:
imSp4rky
2026-10-13 19:31:23 -06:00
parent 6b04d7b137
commit 9f82777394
13 changed files with 117 additions and 39 deletions
+6 -5
View File
@@ -41,8 +41,11 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
os: [ubuntu-latest, windows-latest, macos-latest] os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ["3.11", "3.14"] python-version: ${{ fromJSON(github.event_name == 'push' && github.ref == 'refs/heads/dev' && '["3.11"]' || '["3.11", "3.14"]') }}
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
timeout-minutes: 15
env:
UV_NO_SYNC: "1"
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v8.3.2 - uses: astral-sh/setup-uv@v8.3.2
@@ -50,13 +53,11 @@ jobs:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
enable-cache: true enable-cache: true
- name: Install - name: Install
run: uv sync --locked --group test run: uv sync --locked --no-default-groups --group test
- name: Import check
run: uv run python -c "import unshackle; print(unshackle.__name__, 'imports OK')"
- name: CLI loads - name: CLI loads
run: uv run unshackle --help run: uv run unshackle --help
- name: Tests - name: Tests
run: uv run pytest -m "not live and not slow" run: uv run pytest -n auto --dist worksteal -m "not live and not slow"
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+10 -8
View File
@@ -29,11 +29,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
- uses: actions/setup-python@v6 - uses: astral-sh/setup-uv@v8.3.2
with: with:
python-version: "3.x" python-version: "3.14"
- run: pip install -e . -r docs/requirements-docs.txt enable-cache: true
- run: mkdocs build --strict cache-suffix: docs
- run: uv run --no-project --with-requirements docs/requirements-docs.txt mkdocs build --strict
deploy: deploy:
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
@@ -42,8 +43,9 @@ jobs:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: actions/setup-python@v6 - uses: astral-sh/setup-uv@v8.3.2
with: with:
python-version: "3.x" python-version: "3.14"
- run: pip install -e . -r docs/requirements-docs.txt enable-cache: true
- run: mkdocs gh-deploy --remote-branch docs cache-suffix: docs
- run: uv run --no-project --with-requirements docs/requirements-docs.txt mkdocs gh-deploy --remote-branch docs
+3
View File
@@ -99,6 +99,7 @@ test = [
"pytest>=8.3.0,<9", "pytest>=8.3.0,<9",
"pytest-asyncio>=0.24.0,<1", "pytest-asyncio>=0.24.0,<1",
"pytest-aiohttp>=1.0.5,<2", "pytest-aiohttp>=1.0.5,<2",
"pytest-xdist>=3.6.0,<4",
"responses>=0.25.0,<1", "responses>=0.25.0,<1",
"PyYAML>=6.0.1,<7", "PyYAML>=6.0.1,<7",
] ]
@@ -121,6 +122,8 @@ filterwarnings = [
"ignore::bs4.XMLParsedAsHTMLWarning", "ignore::bs4.XMLParsedAsHTMLWarning",
] ]
addopts = "-ra --strict-markers" addopts = "-ra --strict-markers"
# dumps every thread's stack for a test that hangs; the CI job timeout alone leaves no trace
faulthandler_timeout = 120
[tool.hatch.metadata] [tool.hatch.metadata]
allow-direct-references = true allow-direct-references = true
+40
View File
@@ -0,0 +1,40 @@
"""Suite-wide test setup."""
from __future__ import annotations
import socket
import socketserver
from typing import Any
import pytest
from unshackle.core.update_checker import UpdateChecker
# shutdown() blocks until the serve loop polls again, and the 0.5s default is paid by every
# test server at teardown. The default changes for the whole process, servers in core included.
socketserver.BaseServer.serve_forever.__defaults__ = (0.01,)
@pytest.fixture(autouse=True)
def loopback_only(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> None:
"""Fail a name lookup for any host but loopback, so a stray request cannot hang a unit test."""
if request.node.get_closest_marker("live") or request.node.get_closest_marker("slow"):
return
resolve = socket.getaddrinfo
def offline(host: Any, *args: Any, **kwargs: Any) -> Any:
if host not in (None, "", "localhost", "127.0.0.1", "::1"):
raise socket.gaierror(socket.EAI_NONAME, f"unit tests are offline: {host!r} is not loopback")
return resolve(host, *args, **kwargs)
monkeypatch.setattr(socket, "getaddrinfo", offline)
@pytest.fixture(autouse=True)
def no_update_check(monkeypatch: pytest.MonkeyPatch) -> None:
"""Keep the release lookup of the health route off the network."""
async def no_release(*args: Any, **kwargs: Any) -> None:
return None
monkeypatch.setattr(UpdateChecker, "check_for_updates", no_release)
+3 -1
View File
@@ -104,7 +104,8 @@ def test_encoded_body_is_decoded_on_disk(tmp_path, encoding_server):
assert save_path.read_bytes() == PLAIN assert save_path.read_bytes() == PLAIN
def test_encoded_body_discards_a_partial_instead_of_range_resuming(tmp_path, encoding_server): def test_encoded_body_discards_a_partial_instead_of_range_resuming(tmp_path, encoding_server, monkeypatch):
monkeypatch.setattr(importlib.import_module("unshackle.core.downloaders.requests"), "RETRY_WAIT", 0)
save_path = tmp_path / "sub.vtt" save_path = tmp_path / "sub.vtt"
save_path.with_name("sub.vtt.!dev").write_bytes(PLAIN[:5000]) save_path.with_name("sub.vtt.!dev").write_bytes(PLAIN[:5000])
list(download(url=encoding_server.url, save_path=save_path, session=rq.Session())) list(download(url=encoding_server.url, save_path=save_path, session=rq.Session()))
@@ -147,6 +148,7 @@ class _FakeRnetSession:
def test_rnet_encoded_body_discards_a_partial_instead_of_range_resuming(tmp_path, monkeypatch): def test_rnet_encoded_body_discards_a_partial_instead_of_range_resuming(tmp_path, monkeypatch):
dl = importlib.import_module("unshackle.core.downloaders.requests") dl = importlib.import_module("unshackle.core.downloaders.requests")
monkeypatch.setattr(dl, "is_rnet_session", lambda s: isinstance(s, _FakeRnetSession)) monkeypatch.setattr(dl, "is_rnet_session", lambda s: isinstance(s, _FakeRnetSession))
monkeypatch.setattr(dl, "RETRY_WAIT", 0)
session = _FakeRnetSession(PLAIN) session = _FakeRnetSession(PLAIN)
save_path = tmp_path / "sub.vtt" save_path = tmp_path / "sub.vtt"
save_path.with_name("sub.vtt.!dev").write_bytes(PLAIN[:5000]) save_path.with_name("sub.vtt.!dev").write_bytes(PLAIN[:5000])
+2 -2
View File
@@ -25,7 +25,7 @@ SLOW_INDEX = 4
FAST_BODY = b"F" * 4096 FAST_BODY = b"F" * 4096
SLOW_BODY = b"S" * 40960 SLOW_BODY = b"S" * 40960
TRICKLE_CHUNK = 2048 TRICKLE_CHUNK = 2048
TRICKLE_DELAY = 0.05 TRICKLE_DELAY = 0.5
class _Handler(BaseHTTPRequestHandler): class _Handler(BaseHTTPRequestHandler):
@@ -82,7 +82,7 @@ def test_hedge_loser_releases_handles_before_merge(tmp_path, trickle_server, mon
slow_hits = trickle_server.counts[f"/seg/{SLOW_INDEX}"] slow_hits = trickle_server.counts[f"/seg/{SLOW_INDEX}"]
assert slow_hits == 2, "hedge never fired; the race under test was not exercised" assert slow_hits == 2, "hedge never fired; the race under test was not exercised"
# the batch must not stall draining the loser's stream (trickle alone takes ~1s) # the batch must not stall draining the loser's stream (trickle alone takes ~10s)
assert wall < 5 assert wall < 5
for i in range(SEGMENTS): for i in range(SEGMENTS):
+14 -2
View File
@@ -91,9 +91,21 @@ def test_bucket_holds_aggregate_rate_across_threads():
for t in threads: for t in threads:
t.join() t.join()
achieved = sum(totals) / (time.monotonic() - start) achieved = sum(totals) / (time.monotonic() - start)
# exceeding the limit is a real failure; undershoot is scheduler noise on a loaded box # exceeding the limit is a real failure; undershoot is scheduler noise on a loaded box, so no lower bound
assert achieved <= rate * 1.1 assert achieved <= rate * 1.1
assert achieved >= rate * 0.5
def test_bucket_refills_at_the_rate(monkeypatch):
"""On a simulated clock the throughput is exact, so a slow refill fails like a fast one."""
clock = [0.0]
monkeypatch.setattr(requests_downloader, "time", SimpleNamespace(monotonic=lambda: clock[0]))
monkeypatch.setattr(
requests_downloader, "DOWNLOAD_CANCELLED", SimpleNamespace(wait=lambda s: clock.__setitem__(0, clock[0] + s))
)
bucket = TokenBucket(1_000)
for _ in range(50):
bucket.consume(100)
assert clock[0] == pytest.approx(5.0)
def test_bucket_chunk_larger_than_capacity_does_not_deadlock(monkeypatch): def test_bucket_chunk_larger_than_capacity_does_not_deadlock(monkeypatch):
@@ -80,6 +80,7 @@ class FakeService:
def run(monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, episodes: dict, **flags: Any) -> Any: def run(monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, episodes: dict, **flags: Any) -> Any:
"""Return the Selected ids, the exit code, or None for a run that completes.""" """Return the Selected ids, the exit code, or None for a run that completes."""
monkeypatch.setattr(Tracks, "tree", lambda self, *a, **k: (_ for _ in ()).throw(Selected(self))) monkeypatch.setattr(Tracks, "tree", lambda self, *a, **k: (_ for _ in ()).throw(Selected(self)))
monkeypatch.setattr("unshackle.core.providers.search_metadata", lambda *a, **k: None)
cmd = Harness.__new__(Harness) cmd = Harness.__new__(Harness)
cmd.log = logging.getLogger("test_kind_only") cmd.log = logging.getLogger("test_kind_only")
cmd.service = "FAKE" cmd.service = "FAKE"
+1 -1
View File
@@ -121,7 +121,7 @@ uv run pytest tests/remote -v --live
Run only fast unit tests: Run only fast unit tests:
```powershell ```powershell
uv run pytest tests/remote -m "unit and not slow" uv run pytest tests/remote -n auto -m "unit and not slow"
``` ```
## Adding a new service to e2e ## Adding a new service to e2e
+10 -4
View File
@@ -6,7 +6,6 @@ import asyncio
import logging import logging
import sys import sys
import threading import threading
import time
from collections.abc import Callable from collections.abc import Callable
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@@ -354,9 +353,11 @@ async def test_status_reports_no_session_limit(aiohttp_client, dashboard_cfg, mo
assert body["max_sessions"] is None assert body["max_sessions"] is None
async def test_dashboard_health(aiohttp_client, dashboard_cfg) -> None: async def test_dashboard_health(aiohttp_client, dashboard_cfg, monkeypatch) -> None:
from unshackle.core.api import handlers from unshackle.core.api import handlers
monkeypatch.setattr(config, "key_vaults", [])
monkeypatch.setattr(config, "proxy_providers", {})
handlers._health_cache.clear() handlers._health_cache.clear()
client = await aiohttp_client(make_app()) client = await aiohttp_client(make_app())
body = await (await client.get("/api/dashboard/health", headers={"X-Secret-Key": "dash-secret"})).json() body = await (await client.get("/api/dashboard/health", headers={"X-Secret-Key": "dash-secret"})).json()
@@ -617,10 +618,12 @@ async def test_health_probe_timeout_reports_what_finished(aiohttp_client, dashbo
"""A vault that accepts the connection then stalls must not hang the panel forever.""" """A vault that accepts the connection then stalls must not hang the panel forever."""
from unshackle.core.api import handlers from unshackle.core.api import handlers
released = threading.Event()
def stalling_probe(checks=None): def stalling_probe(checks=None):
checks = [] if checks is None else checks checks = [] if checks is None else checks
checks.append({"id": "ffmpeg", "label": "ffmpeg", "status": "ok", "detail": "7.1", "ms": 1.0}) checks.append({"id": "ffmpeg", "label": "ffmpeg", "status": "ok", "detail": "7.1", "ms": 1.0})
time.sleep(6) # the stalled vault; the deadline fires long before this returns released.wait(6) # the stalled vault; the deadline fires long before this returns
checks.append({"id": "never", "label": "never", "status": "ok", "detail": "", "ms": 0.0}) checks.append({"id": "never", "label": "never", "status": "ok", "detail": "", "ms": 0.0})
return checks return checks
@@ -629,7 +632,10 @@ async def test_health_probe_timeout_reports_what_finished(aiohttp_client, dashbo
monkeypatch.setattr(handlers, "HEALTH_PROBE_TIMEOUT", 1.0) monkeypatch.setattr(handlers, "HEALTH_PROBE_TIMEOUT", 1.0)
client = await aiohttp_client(make_app()) client = await aiohttp_client(make_app())
body = await (await client.get("/api/dashboard/health", headers={"X-Secret-Key": "dash-secret"})).json() try:
body = await (await client.get("/api/dashboard/health", headers={"X-Secret-Key": "dash-secret"})).json()
finally:
released.set() # an interpreter exit joins the probe thread, so do not leave it asleep
ids = [c["id"] for c in body["checks"]] ids = [c["id"] for c in body["checks"]]
assert "ffmpeg" in ids, "the probe that finished before the deadline must survive" assert "ffmpeg" in ids, "the probe that finished before the deadline must survive"
+2 -15
View File
@@ -233,13 +233,7 @@ async def test_cors_preflight_returns_headers(make_app, aiohttp_client) -> None:
assert "X-Secret-Key" in resp.headers["Access-Control-Allow-Headers"] assert "X-Secret-Key" in resp.headers["Access-Control-Allow-Headers"]
async def test_health_endpoint_responds_ok(make_app, aiohttp_client, monkeypatch: pytest.MonkeyPatch) -> None: async def test_health_endpoint_responds_ok(make_app, aiohttp_client) -> None:
from unshackle.core.api import routes as routes_mod
async def no_update(_):
return None
monkeypatch.setattr(routes_mod.UpdateChecker, "check_for_updates", no_update)
app = make_app(remote_only=True) app = make_app(remote_only=True)
client = await aiohttp_client(app) client = await aiohttp_client(app)
resp = await client.get("/api/health") resp = await client.get("/api/health")
@@ -249,14 +243,7 @@ async def test_health_endpoint_responds_ok(make_app, aiohttp_client, monkeypatch
assert "version" in body assert "version" in body
async def test_health_bypasses_api_key_auth_middleware(make_app, aiohttp_client, monkeypatch) -> None: async def test_health_bypasses_api_key_auth_middleware(make_app, aiohttp_client) -> None:
from unshackle.core.api import routes as routes_mod
async def no_update(_):
return None
monkeypatch.setattr(routes_mod.UpdateChecker, "check_for_updates", no_update)
app = make_app(remote_only=True, with_auth_middleware=True) app = make_app(remote_only=True, with_auth_middleware=True)
client = await aiohttp_client(app) client = await aiohttp_client(app)
resp = await client.get("/api/health") resp = await client.get("/api/health")
+1 -1
View File
@@ -158,7 +158,7 @@ def test_a_music_post_script_command_builds_the_argv_a_script_would_get(monkeypa
dispatch("success", "file", context) dispatch("success", "file", context)
for _ in range(100): for _ in range(100):
if marker.exists(): if marker.exists() and marker.stat().st_size:
break break
import time import time
Generated
+24
View File
@@ -554,6 +554,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/35/4a113189f7138035a21bd255d30dc7bffc77c942c93b7948d2eac2e22429/ECPy-1.2.5-py3-none-any.whl", hash = "sha256:559c92e42406d9d1a6b2b8fc26e6ad7bc985f33903b72f426a56cb1073a25ce3", size = 43075, upload-time = "2020-10-26T11:56:13.613Z" }, { url = "https://files.pythonhosted.org/packages/e8/35/4a113189f7138035a21bd255d30dc7bffc77c942c93b7948d2eac2e22429/ECPy-1.2.5-py3-none-any.whl", hash = "sha256:559c92e42406d9d1a6b2b8fc26e6ad7bc985f33903b72f426a56cb1073a25ce3", size = 43075, upload-time = "2020-10-26T11:56:13.613Z" },
] ]
[[package]]
name = "execnet"
version = "2.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" },
]
[[package]] [[package]]
name = "fastjsonschema" name = "fastjsonschema"
version = "2.19.1" version = "2.19.1"
@@ -1683,6 +1692,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/20/7f/338843f449ace853647ace35870874f69a764d251872ed1b4de9f234822c/pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0", size = 19694, upload-time = "2025-03-25T06:22:27.807Z" }, { url = "https://files.pythonhosted.org/packages/20/7f/338843f449ace853647ace35870874f69a764d251872ed1b4de9f234822c/pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0", size = 19694, upload-time = "2025-03-25T06:22:27.807Z" },
] ]
[[package]]
name = "pytest-xdist"
version = "3.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "execnet" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" },
]
[[package]] [[package]]
name = "python-discovery" name = "python-discovery"
version = "1.2.0" version = "1.2.0"
@@ -2205,6 +2227,7 @@ test = [
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-aiohttp" }, { name = "pytest-aiohttp" },
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
{ name = "pytest-xdist" },
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "responses" }, { name = "responses" },
] ]
@@ -2274,6 +2297,7 @@ test = [
{ name = "pytest", specifier = ">=8.3.0,<9" }, { name = "pytest", specifier = ">=8.3.0,<9" },
{ name = "pytest-aiohttp", specifier = ">=1.0.5,<2" }, { name = "pytest-aiohttp", specifier = ">=1.0.5,<2" },
{ name = "pytest-asyncio", specifier = ">=0.24.0,<1" }, { name = "pytest-asyncio", specifier = ">=0.24.0,<1" },
{ name = "pytest-xdist", specifier = ">=3.6.0,<4" },
{ name = "pyyaml", specifier = ">=6.0.1,<7" }, { name = "pyyaml", specifier = ">=6.0.1,<7" },
{ name = "responses", specifier = ">=0.25.0,<1" }, { name = "responses", specifier = ">=0.25.0,<1" },
] ]