diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 123c1279..80fdcdf8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,8 +41,11 @@ jobs: fail-fast: false matrix: 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 }} + timeout-minutes: 15 + env: + UV_NO_SYNC: "1" steps: - uses: actions/checkout@v7 - uses: astral-sh/setup-uv@v8.3.2 @@ -50,13 +53,11 @@ jobs: python-version: ${{ matrix.python-version }} enable-cache: true - name: Install - run: uv sync --locked --group test - - name: Import check - run: uv run python -c "import unshackle; print(unshackle.__name__, 'imports OK')" + run: uv sync --locked --no-default-groups --group test - name: CLI loads run: uv run unshackle --help - 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: runs-on: ubuntu-latest diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index b2e603a0..4d79a803 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -29,11 +29,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - uses: actions/setup-python@v6 + - uses: astral-sh/setup-uv@v8.3.2 with: - python-version: "3.x" - - run: pip install -e . -r docs/requirements-docs.txt - - run: mkdocs build --strict + python-version: "3.14" + enable-cache: true + cache-suffix: docs + - run: uv run --no-project --with-requirements docs/requirements-docs.txt mkdocs build --strict deploy: if: github.event_name != 'pull_request' @@ -42,8 +43,9 @@ jobs: - uses: actions/checkout@v7 with: fetch-depth: 0 - - uses: actions/setup-python@v6 + - uses: astral-sh/setup-uv@v8.3.2 with: - python-version: "3.x" - - run: pip install -e . -r docs/requirements-docs.txt - - run: mkdocs gh-deploy --remote-branch docs + python-version: "3.14" + enable-cache: true + cache-suffix: docs + - run: uv run --no-project --with-requirements docs/requirements-docs.txt mkdocs gh-deploy --remote-branch docs diff --git a/pyproject.toml b/pyproject.toml index bf413df3..c967b56f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,6 +99,7 @@ test = [ "pytest>=8.3.0,<9", "pytest-asyncio>=0.24.0,<1", "pytest-aiohttp>=1.0.5,<2", + "pytest-xdist>=3.6.0,<4", "responses>=0.25.0,<1", "PyYAML>=6.0.1,<7", ] @@ -121,6 +122,8 @@ filterwarnings = [ "ignore::bs4.XMLParsedAsHTMLWarning", ] 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] allow-direct-references = true diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..6126e457 --- /dev/null +++ b/tests/conftest.py @@ -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) diff --git a/tests/downloaders/test_content_encoding.py b/tests/downloaders/test_content_encoding.py index 816a593a..e0ebeb49 100644 --- a/tests/downloaders/test_content_encoding.py +++ b/tests/downloaders/test_content_encoding.py @@ -104,7 +104,8 @@ def test_encoded_body_is_decoded_on_disk(tmp_path, encoding_server): 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.with_name("sub.vtt.!dev").write_bytes(PLAIN[:5000]) 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): dl = importlib.import_module("unshackle.core.downloaders.requests") monkeypatch.setattr(dl, "is_rnet_session", lambda s: isinstance(s, _FakeRnetSession)) + monkeypatch.setattr(dl, "RETRY_WAIT", 0) session = _FakeRnetSession(PLAIN) save_path = tmp_path / "sub.vtt" save_path.with_name("sub.vtt.!dev").write_bytes(PLAIN[:5000]) diff --git a/tests/downloaders/test_hedge_handles.py b/tests/downloaders/test_hedge_handles.py index 7c7e633c..96dd8cfe 100644 --- a/tests/downloaders/test_hedge_handles.py +++ b/tests/downloaders/test_hedge_handles.py @@ -25,7 +25,7 @@ SLOW_INDEX = 4 FAST_BODY = b"F" * 4096 SLOW_BODY = b"S" * 40960 TRICKLE_CHUNK = 2048 -TRICKLE_DELAY = 0.05 +TRICKLE_DELAY = 0.5 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}"] 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 for i in range(SEGMENTS): diff --git a/tests/downloaders/test_speed_limit.py b/tests/downloaders/test_speed_limit.py index 636ae691..d468bb87 100644 --- a/tests/downloaders/test_speed_limit.py +++ b/tests/downloaders/test_speed_limit.py @@ -91,9 +91,21 @@ def test_bucket_holds_aggregate_rate_across_threads(): for t in threads: t.join() 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 * 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): diff --git a/tests/lang_selection/test_kind_only_selection.py b/tests/lang_selection/test_kind_only_selection.py index 544f358c..e6a1bbaa 100644 --- a/tests/lang_selection/test_kind_only_selection.py +++ b/tests/lang_selection/test_kind_only_selection.py @@ -80,6 +80,7 @@ class FakeService: 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.""" 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.log = logging.getLogger("test_kind_only") cmd.service = "FAKE" diff --git a/tests/remote/README.md b/tests/remote/README.md index 2f041917..5218e823 100644 --- a/tests/remote/README.md +++ b/tests/remote/README.md @@ -121,7 +121,7 @@ uv run pytest tests/remote -v --live Run only fast unit tests: ```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 diff --git a/tests/remote/unit/test_dashboard.py b/tests/remote/unit/test_dashboard.py index 62af81f7..8f36fbe9 100644 --- a/tests/remote/unit/test_dashboard.py +++ b/tests/remote/unit/test_dashboard.py @@ -6,7 +6,6 @@ import asyncio import logging import sys import threading -import time from collections.abc import Callable 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 -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 + monkeypatch.setattr(config, "key_vaults", []) + monkeypatch.setattr(config, "proxy_providers", {}) handlers._health_cache.clear() client = await aiohttp_client(make_app()) 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.""" from unshackle.core.api import handlers + released = threading.Event() + def stalling_probe(checks=None): checks = [] if checks is None else checks 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}) 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) 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"]] assert "ffmpeg" in ids, "the probe that finished before the deadline must survive" diff --git a/tests/remote/unit/test_routes.py b/tests/remote/unit/test_routes.py index 573a380b..cfbbb2f2 100644 --- a/tests/remote/unit/test_routes.py +++ b/tests/remote/unit/test_routes.py @@ -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"] -async def test_health_endpoint_responds_ok(make_app, aiohttp_client, monkeypatch: pytest.MonkeyPatch) -> 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) +async def test_health_endpoint_responds_ok(make_app, aiohttp_client) -> None: app = make_app(remote_only=True) client = await aiohttp_client(app) 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 -async def test_health_bypasses_api_key_auth_middleware(make_app, aiohttp_client, monkeypatch) -> 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) - +async def test_health_bypasses_api_key_auth_middleware(make_app, aiohttp_client) -> None: app = make_app(remote_only=True, with_auth_middleware=True) client = await aiohttp_client(app) resp = await client.get("/api/health") diff --git a/tests/test_post_scripts_music.py b/tests/test_post_scripts_music.py index 9d38f616..62d6ef55 100644 --- a/tests/test_post_scripts_music.py +++ b/tests/test_post_scripts_music.py @@ -158,7 +158,7 @@ def test_a_music_post_script_command_builds_the_argv_a_script_would_get(monkeypa dispatch("success", "file", context) for _ in range(100): - if marker.exists(): + if marker.exists() and marker.stat().st_size: break import time diff --git a/uv.lock b/uv.lock index ff78452a..f112c7a9 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, ] +[[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]] name = "fastjsonschema" 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" }, ] +[[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]] name = "python-discovery" version = "1.2.0" @@ -2205,6 +2227,7 @@ test = [ { name = "pytest" }, { name = "pytest-aiohttp" }, { name = "pytest-asyncio" }, + { name = "pytest-xdist" }, { name = "pyyaml" }, { name = "responses" }, ] @@ -2274,6 +2297,7 @@ test = [ { name = "pytest", specifier = ">=8.3.0,<9" }, { name = "pytest-aiohttp", specifier = ">=1.0.5,<2" }, { name = "pytest-asyncio", specifier = ">=0.24.0,<1" }, + { name = "pytest-xdist", specifier = ">=3.6.0,<4" }, { name = "pyyaml", specifier = ">=6.0.1,<7" }, { name = "responses", specifier = ">=0.25.0,<1" }, ]