mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-11 19:45:06 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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])
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user