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
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
+10 -8
View File
@@ -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
+3
View File
@@ -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
+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
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])
+2 -2
View File
@@ -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):
+14 -2
View File
@@ -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):
@@ -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"
+1 -1
View File
@@ -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
+10 -4
View File
@@ -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"
+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"]
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")
+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)
for _ in range(100):
if marker.exists():
if marker.exists() and marker.stat().st_size:
break
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" },
]
[[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" },
]