From 75aeff192935c924aca3a71bf9f0d998079888b1 Mon Sep 17 00:00:00 2001 From: Hood Chatham Date: Tue, 1 Sep 2026 13:48:41 -0700 Subject: [PATCH] chore: Factor out common conftest logic betweeen django-cf and runtime-sdk --- packages/django-cf/pyproject.toml | 4 + packages/django-cf/tests/conftest.py | 269 +++--------------- .../tests/in_worker/test_in_worker.py | 30 +- packages/django-cf/uv.lock | 22 ++ packages/runtime-sdk/pyproject.toml | 4 + packages/runtime-sdk/tests/conftest.py | 231 ++------------- packages/runtime-sdk/tests/test_in_workerd.py | 10 +- packages/runtime-sdk/uv.lock | 22 ++ packages/testlib/pyproject.toml | 9 +- packages/testlib/testlib/host.py | 257 +++++++++++++++++ 10 files changed, 392 insertions(+), 466 deletions(-) create mode 100644 packages/testlib/testlib/host.py diff --git a/packages/django-cf/pyproject.toml b/packages/django-cf/pyproject.toml index 54070ba2..5a8f02cb 100644 --- a/packages/django-cf/pyproject.toml +++ b/packages/django-cf/pyproject.toml @@ -34,8 +34,12 @@ dev = [ "requests", "ruff", "django", + "testlib[host]", ] +[tool.uv.sources] +testlib = { path = "../testlib", editable = true } + [project.urls] "Homepage" = "https://github.com/cloudflare/workers-py" "Bug Reports" = "https://github.com/cloudflare/workers-py/issues" diff --git a/packages/django-cf/tests/conftest.py b/packages/django-cf/tests/conftest.py index 653d8905..ebd4244b 100644 --- a/packages/django-cf/tests/conftest.py +++ b/packages/django-cf/tests/conftest.py @@ -1,26 +1,27 @@ -"""Host-side fixtures that serve the test workers with ``pywrangler dev``. - -TODO: reduce the duplication between this file and packages/runtime-sdk/tests/conftest.py -""" +"""Host-side fixtures that serve the test workers with ``pywrangler dev``.""" # pyright: reportMissingImports=false, reportMissingModuleSource=false -import ast -import contextlib -import functools import os import shutil -import signal -import socket import subprocess -import time -from collections.abc import Callable, Generator -from dataclasses import dataclass, field +from collections.abc import Generator +from dataclasses import dataclass from pathlib import Path -from typing import Any, Literal, TypedDict import pytest import requests +from testlib.host import ( + COMPAT_CONFIGS, + CompatConfig, + configure_compatibility, +) +from testlib.host import ( + dev_server as run_dev_server, +) +from testlib.host import ( + register_in_worker_suites as register_testlib_suites, +) TEST_DIR: Path = Path(__file__).parent PACKAGE_DIR: Path = TEST_DIR.parent @@ -35,12 +36,7 @@ IN_WORKER_PROJECT: Path = TEST_DIR / "in_worker" / "worker" DEV_STARTUP_TIMEOUT: int = 240 -DEV_POLL_INTERVAL: float = 0.5 SEED_TIMEOUT: int = 180 -TEARDOWN_TIMEOUT: int = 10 -SUITE_CONNECT_TIMEOUT: int = 10 -SUITE_READ_TIMEOUT: int = 300 - GENERATED = shutil.ignore_patterns( ".venv", ".venv-workers", @@ -52,128 +48,17 @@ ) -@dataclass(frozen=True) -class CompatConfig: - compat_date: str - python_version: str - extra_compat_flags: list[str] = field(default_factory=list) - - -COMPAT_CONFIGS: list[CompatConfig] = [ - CompatConfig( - compat_date="2025-09-01", - python_version="3.12", - extra_compat_flags=[ - "enable_python_external_sdk", - "python_process_pth_files", - "python_request_headers_preserve_commas", - ], - ), - CompatConfig( - compat_date="2026-01-01", - python_version="3.13", - extra_compat_flags=[ - "enable_python_external_sdk", - "python_process_pth_files", - "python_request_headers_preserve_commas", - ], - ), - CompatConfig( - compat_date="2026-07-01", - python_version="3.14", - # TODO: remove these when 3.14 is stable, and enabled by date - extra_compat_flags=["python_workers_314", "experimental"], - ), -] - - @dataclass(frozen=True) class DevServer: base_url: str -def get_free_port() -> int: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.bind(("127.0.0.1", 0)) - return sock.getsockname()[1] - - -def _terminate(process: subprocess.Popen[bytes]) -> None: - if process.poll() is not None: - return - # `pywrangler dev` spawns npx -> wrangler -> workerd; signalling the group - # is the only way to avoid orphaning workerd. - group = os.getpgid(process.pid) - os.killpg(group, signal.SIGTERM) - try: - process.wait(timeout=TEARDOWN_TIMEOUT) - except subprocess.TimeoutExpired: - os.killpg(group, signal.SIGKILL) - process.wait() - - def _fail(log_path: Path, message: str) -> None: - # Callers run inside `_dev_server`, whose finally block stops the worker. pytest.fail( f"{message}\n\n--- pywrangler dev log ---\n{log_path.read_text(errors='replace')}" ) -def _wait_for_ready( - process: subprocess.Popen[bytes], base_url: str, log_path: Path -) -> None: - """Block until the worker answers. - - Any status counts: a 404 still proves workerd loaded the script and is - routing requests. - """ - deadline = time.monotonic() + DEV_STARTUP_TIMEOUT - while time.monotonic() < deadline: - if process.poll() is not None: - _fail( - log_path, f"pywrangler dev exited early with code {process.returncode}" - ) - try: - requests.get(base_url, timeout=5) - return - except requests.RequestException: - time.sleep(DEV_POLL_INTERVAL) - - _fail(log_path, f"pywrangler dev was not ready within {DEV_STARTUP_TIMEOUT}s") - - -@contextlib.contextmanager -def _dev_server( - target: Path, tmp_path: Path, env: dict[str, str], pywrangler: list[str] -) -> Generator[tuple[str, Path]]: - """Run `pywrangler dev` on a free port, yielding its base URL and log path.""" - port = get_free_port() - base_url = f"http://127.0.0.1:{port}" - log_path = tmp_path / f"{target.name}-dev.log" - - with log_path.open("w") as log_file: - process = subprocess.Popen( - [ - *pywrangler, - "dev", - "--port", - str(port), - "--persist-to", - str(tmp_path / "state"), - ], - cwd=target, - stdout=log_file, - stderr=subprocess.STDOUT, - env=env, - start_new_session=True, - ) - try: - _wait_for_ready(process, base_url, log_path) - yield base_url, log_path - finally: - _terminate(process) - - def _seed(base_url: str, log_path: Path) -> None: for endpoint in ("__run_migrations__", "__create_admin__"): try: @@ -219,7 +104,13 @@ def _serve(project_dir: Path, tmp_path: Path) -> Generator[DevServer]: DJANGO_CF_SRC, vendored, ignore=shutil.ignore_patterns("__pycache__") ) - with _dev_server(target, tmp_path, env, pywrangler) as (base_url, log_path): + with run_dev_server( + target, + tmp_path, + env, + pywrangler, + startup_timeout=DEV_STARTUP_TIMEOUT, + ) as (base_url, log_path): _seed(base_url, log_path) yield DevServer(base_url) @@ -243,19 +134,6 @@ def r2_web_server(tmp_path_factory: pytest.TempPathFactory) -> Generator[DevServ yield from _serve(R2_PROJECT, tmp_path_factory.mktemp("r2")) -def replace_compat_date(file: Path, compat_date: str) -> None: - file.write_text(file.read_text().replace("%COMPAT_DATE", compat_date)) - - -def inject_compat_flags(file: Path, extra_flags: list[str]) -> None: - if not extra_flags: - return - content = file.read_text() - for flag in extra_flags: - content = content.replace('"python_workers"', f'"python_workers", "{flag}"') - file.write_text(content) - - @pytest.fixture(scope="session", autouse=True) def build_testlib(): subprocess.run(["uv", "build"], cwd=TESTLIB, check=True) @@ -271,7 +149,7 @@ def compat_config(request: pytest.FixtureRequest) -> CompatConfig: @pytest.fixture(scope="module") -def in_worker_server( +def dev_server( tmp_path_factory: pytest.TempPathFactory, compat_config: CompatConfig ) -> Generator[str]: """Serve ``tests/in_worker/worker``, once per compat config. @@ -286,8 +164,7 @@ def in_worker_server( shutil.copytree(TESTLIB, tmp_path / "testlib", ignore=GENERATED) wrangler_jsonc = target / "wrangler.jsonc" - replace_compat_date(wrangler_jsonc, compat_config.compat_date) - inject_compat_flags(wrangler_jsonc, compat_config.extra_compat_flags) + configure_compatibility(wrangler_jsonc, compat_config) pywrangler = [ "uv", @@ -310,97 +187,15 @@ def in_worker_server( ignore=shutil.ignore_patterns("__pycache__"), ) - with _dev_server(target, tmp_path, env, pywrangler) as (base_url, _): + with run_dev_server( + target, + tmp_path, + env, + pywrangler, + startup_timeout=DEV_STARTUP_TIMEOUT, + ) as (base_url, _): yield base_url -class InWorkerTestResult(TypedDict): - status: Literal["passed", "failed", "error", "skipped"] - error: str - traceback: str - reason: str - - -SuiteResults = dict[str, InWorkerTestResult] - - -@functools.cache -def get_suite_results(in_worker_server: str, suite: str) -> SuiteResults | str: - try: - resp = requests.get( - f"{in_worker_server}/run-tests/{suite}", - timeout=(SUITE_CONNECT_TIMEOUT, SUITE_READ_TIMEOUT), - ) - except requests.RequestException as error: - return f"Suite '{suite}' request failed: {error}" - if not resp.ok: - return f"Suite '{suite}' returned {resp.status_code}: {resp.text}" - return resp.json() - - -def _make_test(suite: str, test_name: str) -> Callable: - def test_fn(self: Any, in_worker_server: str) -> None: - results = get_suite_results(in_worker_server, suite) - if isinstance(results, str): - pytest.fail(results) - return - result: InWorkerTestResult | None = results.get(test_name) - assert result is not None, ( - f"Test {suite}::{test_name} not found in results; " - f"available keys: {sorted(results)}" - ) - if result["status"] == "skipped": - pytest.skip(result.get("reason", "")) - elif result["status"] == "failed": - pytest.fail(result["error"]) - elif result["status"] == "error": - pytest.fail(f"{result['error']}\n{result.get('traceback', '')}") - - test_fn.__name__ = f"test_{test_name}" - return test_fn - - -def make_suite_class(suite: str, tests: list[str]) -> type: - camel = "".join(part.title() for part in suite.split("_")) - return type( - f"Test{camel}", - (), - {f"test_{name}": _make_test(suite, name) for name in tests}, - ) - - -def _normalize_test_name(*parts: str) -> str: - normalized = [] - for part in parts: - normalized.append(part[len("test_") :] if part.startswith("test_") else part) - return "__".join(normalized) - - -def discover_test_names(module_path: Path) -> list[str]: - tree = ast.parse(module_path.read_text()) - names = [] - for node in tree.body: - if isinstance( - node, ast.FunctionDef | ast.AsyncFunctionDef - ) and node.name.startswith("test_"): - names.append(_normalize_test_name(node.name)) - elif isinstance(node, ast.ClassDef): - for child in node.body: - if isinstance( - child, ast.FunctionDef | ast.AsyncFunctionDef - ) and child.name.startswith("test_"): - names.append(_normalize_test_name(node.name, child.name)) - return names - - -def discover_suites(src_dir: Path) -> dict[str, list[str]]: - return { - module_path.stem[len("test_") :]: discover_test_names(module_path) - for module_path in sorted(src_dir.glob("test_*.py")) - } - - -def register_in_worker_suites(namespace: dict[str, Any], src_dir: Path) -> None: - for suite, test_names in discover_suites(src_dir).items(): - suite_cls = make_suite_class(suite, test_names) - namespace[suite_cls.__name__] = suite_cls +def register_in_worker_suites(namespace: dict, src_dir: Path) -> None: + register_testlib_suites(namespace, src_dir) diff --git a/packages/django-cf/tests/in_worker/test_in_worker.py b/packages/django-cf/tests/in_worker/test_in_worker.py index 0a01a35d..07d74cb9 100644 --- a/packages/django-cf/tests/in_worker/test_in_worker.py +++ b/packages/django-cf/tests/in_worker/test_in_worker.py @@ -32,9 +32,9 @@ def compat_config(request: pytest.FixtureRequest) -> CompatConfig: register_in_worker_suites(globals(), IN_WORKER_SRC_DIR) -def test_django_wsgi_header_transformation(in_worker_server: str) -> None: +def test_django_wsgi_header_transformation(dev_server: str) -> None: response = requests.get( - f"{in_worker_server}/django/headers/", + f"{dev_server}/django/headers/", headers={ "cf-access-jwt-assertion": "jwt-token", "x-custom-header": "custom-value", @@ -50,9 +50,9 @@ def test_django_wsgi_header_transformation(in_worker_server: str) -> None: assert payload["content_type"] == "text/plain" -def test_django_wsgi_reads_post_request_body(in_worker_server: str) -> None: +def test_django_wsgi_reads_post_request_body(dev_server: str) -> None: response = requests.post( - f"{in_worker_server}/django/body/", + f"{dev_server}/django/body/", headers={"content-type": "text/plain"}, data=b"request-body", timeout=10, @@ -62,26 +62,24 @@ def test_django_wsgi_reads_post_request_body(in_worker_server: str) -> None: assert response.content == b"request-body" -def test_django_wsgi_preserves_binary_response(in_worker_server: str) -> None: - response = requests.get(f"{in_worker_server}/django/binary/", timeout=10) +def test_django_wsgi_preserves_binary_response(dev_server: str) -> None: + response = requests.get(f"{dev_server}/django/binary/", timeout=10) assert response.status_code == 200 assert response.headers["content-type"] == "application/octet-stream" assert response.content == bytes(range(256)) -def test_django_wsgi_streams_response(in_worker_server: str) -> None: - response = requests.get( - f"{in_worker_server}/django/stream/", stream=True, timeout=10 - ) +def test_django_wsgi_streams_response(dev_server: str) -> None: + response = requests.get(f"{dev_server}/django/stream/", stream=True, timeout=10) assert response.status_code == 200 assert "content-length" not in response.headers assert response.content == b"".join(bytes([value]) * 1024 for value in range(5)) -def test_django_wsgi_preserves_multiple_cookies(in_worker_server: str) -> None: - response = requests.get(f"{in_worker_server}/django/cookies/", timeout=10) +def test_django_wsgi_preserves_multiple_cookies(dev_server: str) -> None: + response = requests.get(f"{dev_server}/django/cookies/", timeout=10) assert response.status_code == 200 assert len(response.raw.headers.getlist("set-cookie")) == 2 @@ -89,9 +87,9 @@ def test_django_wsgi_preserves_multiple_cookies(in_worker_server: str) -> None: assert response.cookies["second"] == "2" -def test_django_wsgi_builds_request_metadata(in_worker_server: str) -> None: +def test_django_wsgi_builds_request_metadata(dev_server: str) -> None: response = requests.get( - f"{in_worker_server}/django/meta/%E6%9D%B1%E4%BA%AC/", + f"{dev_server}/django/meta/%E6%9D%B1%E4%BA%AC/", params=[("value", "first"), ("value", "second")], timeout=10, ) @@ -106,9 +104,9 @@ def test_django_wsgi_builds_request_metadata(in_worker_server: str) -> None: } -def test_django_wsgi_reads_delete_request_body(in_worker_server: str) -> None: +def test_django_wsgi_reads_delete_request_body(dev_server: str) -> None: response = requests.delete( - f"{in_worker_server}/django/body/", + f"{dev_server}/django/body/", data=b"\x00\xffrequest-body", timeout=10, ) diff --git a/packages/django-cf/uv.lock b/packages/django-cf/uv.lock index 386a1260..c2443ccf 100644 --- a/packages/django-cf/uv.lock +++ b/packages/django-cf/uv.lock @@ -186,6 +186,7 @@ dev = [ { name = "pytest-cov" }, { name = "requests" }, { name = "ruff" }, + { name = "testlib", extra = ["host"] }, ] [package.metadata] @@ -202,6 +203,7 @@ dev = [ { name = "pytest-cov" }, { name = "requests" }, { name = "ruff" }, + { name = "testlib", extras = ["host"], editable = "../testlib" }, ] [[package]] @@ -342,6 +344,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/70/001ee337f7aa888fb2e3f5fd7592a6afc5283adb1ed44ce8df5764070f22/sqlparse-0.5.4-py3-none-any.whl", hash = "sha256:99a9f0314977b76d776a0fcb8554de91b9bb8a18560631d6bc48721d07023dcb", size = 45933, upload-time = "2025-11-28T07:10:19.73Z" }, ] +[[package]] +name = "testlib" +version = "0.0.0" +source = { editable = "../testlib" } +dependencies = [ + { name = "pytest" }, +] + +[package.optional-dependencies] +host = [ + { name = "requests" }, +] + +[package.metadata] +requires-dist = [ + { name = "pytest" }, + { name = "requests", marker = "extra == 'host'" }, +] +provides-extras = ["host"] + [[package]] name = "typing-extensions" version = "4.16.0" diff --git a/packages/runtime-sdk/pyproject.toml b/packages/runtime-sdk/pyproject.toml index b9a1514b..6ac083f2 100644 --- a/packages/runtime-sdk/pyproject.toml +++ b/packages/runtime-sdk/pyproject.toml @@ -21,8 +21,12 @@ dev = [ "ruff>=0.1.0", "mypy>=1.0.0", "requests>=2.28.0", + "testlib[host]", ] +[tool.uv.sources] +testlib = { path = "../testlib", editable = true } + [project.urls] "Homepage" = "https://github.com/cloudflare/workers-py" "Bug Tracker" = "https://github.com/cloudflare/workers-py/issues" diff --git a/packages/runtime-sdk/tests/conftest.py b/packages/runtime-sdk/tests/conftest.py index 01c1147e..10ed8d9b 100644 --- a/packages/runtime-sdk/tests/conftest.py +++ b/packages/runtime-sdk/tests/conftest.py @@ -1,19 +1,24 @@ """Shared fixtures and helpers for the host-side test suite.""" -import ast -import functools import os import shutil -import socket import subprocess -import time -from collections.abc import Callable, Generator -from dataclasses import dataclass, field +from collections.abc import Generator from pathlib import Path -from typing import Any, Literal, TypedDict +from typing import Any import pytest -import requests +from testlib.host import ( + COMPAT_CONFIGS, + CompatConfig, + configure_compatibility, +) +from testlib.host import ( + dev_server as run_dev_server, +) +from testlib.host import ( + register_in_worker_suites as register_testlib_suites, +) TEST_DIR: Path = Path(__file__).parent WORKERS_PY: Path = TEST_DIR.parent.parent / "cli" @@ -21,10 +26,6 @@ TESTLIB: Path = TEST_DIR.parent.parent / "testlib" DEV_STARTUP_TIMEOUT: int = 120 -DEV_POLL_INTERVAL: float = 0.5 -SUITE_CONNECT_TIMEOUT: int = 10 -SUITE_READ_TIMEOUT: int = 300 - OPT_IN_MARKERS: tuple[str, ...] = ("hyperdrive",) @@ -42,94 +43,6 @@ def pytest_collection_modifyitems( item.add_marker(skip) -@dataclass(frozen=True) -class CompatConfig: - compat_date: str - python_version: str - extra_compat_flags: list[str] = field(default_factory=list) - - -COMPAT_CONFIGS: list[CompatConfig] = [ - CompatConfig( - compat_date="2025-09-01", - python_version="3.12", - extra_compat_flags=[ - "enable_python_external_sdk", - "python_process_pth_files", - "python_request_headers_preserve_commas", - ], - ), - CompatConfig( - compat_date="2026-01-01", - python_version="3.13", - extra_compat_flags=[ - "enable_python_external_sdk", - "python_process_pth_files", - "python_request_headers_preserve_commas", - ], - ), - CompatConfig( - compat_date="2026-07-01", - python_version="3.14", - # TODO: remove these when 3.14 is stable, and enabled by date - extra_compat_flags=["python_workers_314", "experimental"], - ), -] - - -def replace_compat_date(file: Path, compat_date: str) -> None: - file.write_text(file.read_text().replace("%COMPAT_DATE", compat_date)) - - -def inject_compat_flags(file: Path, extra_flags: list[str]) -> None: - if not extra_flags: - return - content = file.read_text() - for flag in extra_flags: - content = content.replace('"python_workers"', f'"python_workers", "{flag}"') - file.write_text(content) - - -class InWorkerTestResult(TypedDict): - status: Literal["passed", "failed", "error", "skipped"] - error: str - traceback: str - reason: str - - -SuiteResults = dict[str, InWorkerTestResult] - - -def get_free_port() -> int: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("127.0.0.1", 0)) - return s.getsockname()[1] - - -def wait_for_ready( - process: subprocess.Popen[bytes], base_url: str, log_path: Path -) -> None: - """Poll the /health endpoint until the dev server is accepting requests.""" - deadline = time.time() + DEV_STARTUP_TIMEOUT - while time.time() < deadline: - if process.poll() is not None: - pytest.fail( - f"pywrangler dev exited early with code {process.returncode}\n" - f"stdout: {log_path.read_text(errors='replace')}" - ) - try: - resp = requests.get(f"{base_url}/health", timeout=2) - if resp.ok: - return - except (requests.ConnectionError, requests.Timeout): - pass - time.sleep(DEV_POLL_INTERVAL) - - process.kill() - process.wait() - pytest.fail(f"pywrangler dev did not become ready within {DEV_STARTUP_TIMEOUT}s") - - @pytest.fixture(scope="session", autouse=True) def build_testlib(): subprocess.run(["uv", "build"], cwd=TESTLIB, check=True) @@ -169,8 +82,7 @@ def dev_server( env = os.environ | {"_PYODIDE_EXTRA_MOUNTS": str(tmp_path)} wrangler_jsonc = target / "wrangler.jsonc" - replace_compat_date(wrangler_jsonc, compat_config.compat_date) - inject_compat_flags(wrangler_jsonc, compat_config.extra_compat_flags) + configure_compatibility(wrangler_jsonc, compat_config) pywrangler_cmd = ["uv", "run", "--no-project", "--with", WORKERS_PY, "pywrangler"] @@ -183,115 +95,22 @@ def dev_server( shutil.copytree(WORKERS_RUNTIME_SDK, target / "python_modules", dirs_exist_ok=True) - port: int = get_free_port() - base_url: str = f"http://127.0.0.1:{port}" - - log_path = tmp_path / "dev.log" - with log_path.open("w") as log_file: - process = subprocess.Popen( - [ - *pywrangler_cmd, - "dev", - "--port", - str(port), - "--persist-to", - str(tmp_path / "state"), - ], - cwd=target, - stdout=log_file, - stderr=subprocess.STDOUT, - env=env, - ) - - wait_for_ready(process, base_url, log_path) + with run_dev_server( + target, + tmp_path, + env, + pywrangler_cmd, + startup_timeout=DEV_STARTUP_TIMEOUT, + readiness_path="/health", + require_success=True, + log_name="dev.log", + ) as (base_url, _): yield base_url - process.terminate() - try: - process.wait(timeout=10) - except subprocess.TimeoutExpired: - process.kill() - process.wait() - - -@functools.cache -def get_suite_results(dev_server: str, suite: str) -> SuiteResults | str: - try: - resp = requests.get( - f"{dev_server}/run-tests/{suite}", - timeout=(SUITE_CONNECT_TIMEOUT, SUITE_READ_TIMEOUT), - ) - except requests.RequestException as error: - return f"Suite '{suite}' request failed: {error}" - if not resp.ok: - return f"Suite '{suite}' returned {resp.status_code}: {resp.text}" - return resp.json() - - -def _make_test(suite: str, test_name: str) -> Callable: - def test_fn(self: Any, dev_server: str) -> None: - results = get_suite_results(dev_server, suite) - if isinstance(results, str): - pytest.fail(results) - return - result: InWorkerTestResult | None = results.get(test_name) - assert result is not None, f"Test {suite}::{test_name} not found in results" - if result["status"] == "skipped": - pytest.skip(result.get("reason", "")) - elif result["status"] == "failed": - pytest.fail(result["error"]) - elif result["status"] == "error": - pytest.fail(f"{result['error']}\n{result.get('traceback', '')}") - - test_fn.__name__ = f"test_{test_name}" - return test_fn - - -def make_suite_class(suite: str, tests: list[str]) -> type: - """Build a test class with one method per in-worker test of `suite`.""" - return type( - f"Test{suite.upper()}", - (), - {f"test_{name}": _make_test(suite, name) for name in tests}, - ) - - -def discover_test_names(module_path: Path) -> list[str]: - """Return the suite-relative names of test functions defined in a module. - - Parses the source statically (no import) and strips the ``test_`` prefix so - the names match the keys returned by the in-worker ResultCollector. - """ - tree = ast.parse(module_path.read_text()) - return [ - node.name[len("test_") :] - for node in tree.body - if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) - and node.name.startswith("test_") - ] - - -def discover_suites(src_dir: Path) -> dict[str, list[str]]: - """Map each ``test_.py`` module in `src_dir` to its discovered test names.""" - return { - module_path.stem[len("test_") :]: discover_test_names(module_path) - for module_path in sorted(src_dir.glob("test_*.py")) - } - def register_in_worker_suites( namespace: dict[str, Any], src_dir: Path, marks: dict[str, pytest.MarkDecorator] | None = None, ) -> None: - """Define a ``TestXxx`` class in `namespace` for every suite found in `src_dir`. - - Call with ``globals()`` from a test module so each in-worker test surfaces as - its own pytest case without manual registration. `marks` applies a marker to - the class generated for the suite of the same name. - """ - for suite, test_names in discover_suites(src_dir).items(): - suite_cls = make_suite_class(suite, test_names) - if marks and suite in marks: - suite_cls = marks[suite](suite_cls) - namespace[suite_cls.__name__] = suite_cls + register_testlib_suites(namespace, src_dir, marks=marks, class_name=str.upper) diff --git a/packages/runtime-sdk/tests/test_in_workerd.py b/packages/runtime-sdk/tests/test_in_workerd.py index e9f79234..9b263bb2 100644 --- a/packages/runtime-sdk/tests/test_in_workerd.py +++ b/packages/runtime-sdk/tests/test_in_workerd.py @@ -7,8 +7,7 @@ from conftest import ( COMPAT_CONFIGS, CompatConfig, - inject_compat_flags, - replace_compat_date, + configure_compatibility, ) TEST_DIR = Path(__file__).parent @@ -62,7 +61,7 @@ def bundle_cache_dir(tmp_path_factory): ids=[c.python_version for c in COMPAT_CONFIGS], ) @pytest.mark.parametrize("test_dir, wd_test_file", discover_workerd_tests()) -def test_in_workerd( # noqa: PLR0913 (too-many-arguments) +def test_in_workerd( # noqa: PLR0913, PLR0917 (too-many-arguments) tmp_path, test_dir, wd_test_file, @@ -85,8 +84,7 @@ def test_in_workerd( # noqa: PLR0913 (too-many-arguments) shutil.copytree(test_dir, target, ignore=shutil.ignore_patterns(".venv")) disk_service_dir.mkdir(exist_ok=True) - replace_compat_date(target / "wrangler.jsonc", compat_date) - inject_compat_flags(target / "wrangler.jsonc", compat_config.extra_compat_flags) + configure_compatibility(target / "wrangler.jsonc", compat_config) pywrangler_cmd = ["uv", "run", "--no-project", "--with", WORKERS_PY, "pywrangler"] @@ -114,7 +112,7 @@ def test_in_workerd( # noqa: PLR0913 (too-many-arguments) .replace("%COLOR", str(color).lower()) .replace("%COMPAT_DATE", compat_date) ) - inject_compat_flags(wd_config, compat_config.extra_compat_flags) + configure_compatibility(wd_config, compat_config) subprocess.run( ["npm", "i", "workerd"], diff --git a/packages/runtime-sdk/uv.lock b/packages/runtime-sdk/uv.lock index ef789ed3..52106050 100644 --- a/packages/runtime-sdk/uv.lock +++ b/packages/runtime-sdk/uv.lock @@ -325,6 +325,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" }, ] +[[package]] +name = "testlib" +version = "0.0.0" +source = { editable = "../testlib" } +dependencies = [ + { name = "pytest" }, +] + +[package.optional-dependencies] +host = [ + { name = "requests" }, +] + +[package.metadata] +requires-dist = [ + { name = "pytest" }, + { name = "requests", marker = "extra == 'host'" }, +] +provides-extras = ["host"] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -354,6 +374,7 @@ dev = [ { name = "pytest" }, { name = "requests" }, { name = "ruff" }, + { name = "testlib", extra = ["host"] }, ] [package.metadata] @@ -364,4 +385,5 @@ dev = [ { name = "pytest", specifier = ">=7.0.0" }, { name = "requests", specifier = ">=2.28.0" }, { name = "ruff", specifier = ">=0.1.0" }, + { name = "testlib", extras = ["host"], editable = "../testlib" }, ] diff --git a/packages/testlib/pyproject.toml b/packages/testlib/pyproject.toml index 473e1e31..63422933 100644 --- a/packages/testlib/pyproject.toml +++ b/packages/testlib/pyproject.toml @@ -5,5 +5,12 @@ build-backend = "hatchling.build" [project] name = "testlib" version = "0.0.0" -requires-python = ">=3.12" +requires-python = ">=3.11" dependencies = ["pytest"] + +[project.optional-dependencies] +host = ["requests"] + +[tool.ruff] +extend = "../../ruff.toml" +lint.per-file-ignores."testlib/host.py" = ["PLR2004", "PLR0913"] diff --git a/packages/testlib/testlib/host.py b/packages/testlib/testlib/host.py new file mode 100644 index 00000000..095c7eba --- /dev/null +++ b/packages/testlib/testlib/host.py @@ -0,0 +1,257 @@ +"""Host-side helpers for tests that run pytest inside workerd.""" + +import ast +import contextlib +import functools +import os +import signal +import socket +import subprocess +import time +from collections.abc import Callable, Generator +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Literal, TypedDict + +import pytest +import requests + +SUITE_CONNECT_TIMEOUT = 10 +SUITE_READ_TIMEOUT = 300 + + +@dataclass(frozen=True) +class CompatConfig: + compat_date: str + python_version: str + extra_compat_flags: list[str] = field(default_factory=list) + + +COMPAT_CONFIGS = [ + CompatConfig( + compat_date="2025-09-01", + python_version="3.12", + extra_compat_flags=[ + "enable_python_external_sdk", + "python_process_pth_files", + "python_request_headers_preserve_commas", + ], + ), + CompatConfig( + compat_date="2026-01-01", + python_version="3.13", + extra_compat_flags=[ + "enable_python_external_sdk", + "python_process_pth_files", + "python_request_headers_preserve_commas", + ], + ), + CompatConfig( + compat_date="2026-07-01", + python_version="3.14", + # TODO: remove these when 3.14 is stable and enabled by date. + extra_compat_flags=["python_workers_314", "experimental"], + ), +] + + +def configure_compatibility(file: Path, config: CompatConfig) -> None: + content = file.read_text().replace("%COMPAT_DATE", config.compat_date) + for flag in config.extra_compat_flags: + content = content.replace('"python_workers"', f'"python_workers", "{flag}"') + file.write_text(content) + + +class InWorkerTestResult(TypedDict): + status: Literal["passed", "failed", "error", "skipped"] + error: str + traceback: str + reason: str + + +SuiteResults = dict[str, InWorkerTestResult] + + +def get_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +def _fail(log_path: Path, message: str) -> None: + pytest.fail( + f"{message}\n\n--- pywrangler dev log ---\n" + f"{log_path.read_text(errors='replace')}" + ) + + +def wait_for_ready( # noqa: PLR0913 + process: subprocess.Popen[bytes], + base_url: str, + log_path: Path, + *, + timeout: int, + path: str = "", + require_success: bool = False, + poll_interval: float = 0.5, +) -> None: + """Block until the worker responds according to the configured policy.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if process.poll() is not None: + _fail( + log_path, f"pywrangler dev exited early with code {process.returncode}" + ) + try: + response = requests.get(f"{base_url}{path}", timeout=5) + if not require_success or response.ok: + return + except requests.RequestException: + pass + time.sleep(poll_interval) + + _fail(log_path, f"pywrangler dev was not ready within {timeout}s") + + +def _terminate(process: subprocess.Popen[bytes], timeout: int) -> None: + if process.poll() is not None: + return + group = os.getpgid(process.pid) + os.killpg(group, signal.SIGTERM) + try: + process.wait(timeout=timeout) + except subprocess.TimeoutExpired: + os.killpg(group, signal.SIGKILL) + process.wait() + + +@contextlib.contextmanager +def dev_server( + target: Path, + tmp_path: Path, + env: dict[str, str], + pywrangler: list[str], + *, + startup_timeout: int, + readiness_path: str = "", + require_success: bool = False, + teardown_timeout: int = 10, + log_name: str | None = None, +) -> Generator[tuple[str, Path]]: + """Run ``pywrangler dev`` and yield its base URL and log path.""" + port = get_free_port() + base_url = f"http://127.0.0.1:{port}" + log_path = tmp_path / (log_name or f"{target.name}-dev.log") + + with log_path.open("w") as log_file: + process = subprocess.Popen( + [ + *pywrangler, + "dev", + "--port", + str(port), + "--persist-to", + str(tmp_path / "state"), + ], + cwd=target, + stdout=log_file, + stderr=subprocess.STDOUT, + env=env, + start_new_session=True, + ) + try: + wait_for_ready( + process, + base_url, + log_path, + timeout=startup_timeout, + path=readiness_path, + require_success=require_success, + ) + yield base_url, log_path + finally: + _terminate(process, teardown_timeout) + + +@functools.cache +def get_suite_results(server: str, suite: str) -> SuiteResults | str: + try: + response = requests.get( + f"{server}/run-tests/{suite}", + timeout=(SUITE_CONNECT_TIMEOUT, SUITE_READ_TIMEOUT), + ) + except requests.RequestException as error: + return f"Suite '{suite}' request failed: {error}" + if not response.ok: + return f"Suite '{suite}' returned {response.status_code}: {response.text}" + return response.json() + + +def _make_test(suite: str, test_name: str) -> Callable: + def test_fn(self: Any, dev_server: str) -> None: + results = get_suite_results(dev_server, suite) + if isinstance(results, str): + pytest.fail(results) + result = results.get(test_name) + assert result is not None, ( + f"Test {suite}::{test_name} not found in results; " + f"available keys: {sorted(results)}" + ) + if result["status"] == "skipped": + pytest.skip(result.get("reason", "")) + if result["status"] == "failed": + pytest.fail(result["error"]) + if result["status"] == "error": + pytest.fail(f"{result['error']}\n{result.get('traceback', '')}") + + test_fn.__name__ = f"test_{test_name}" + return test_fn + + +def _normalize_test_name(*parts: str) -> str: + return "__".join(part.removeprefix("test_") for part in parts) + + +def discover_test_names(module_path: Path) -> list[str]: + tree = ast.parse(module_path.read_text()) + names = [] + for node in tree.body: + if isinstance( + node, ast.FunctionDef | ast.AsyncFunctionDef + ) and node.name.startswith("test_"): + names.append(_normalize_test_name(node.name)) + elif isinstance(node, ast.ClassDef): + for child in node.body: + if isinstance( + child, ast.FunctionDef | ast.AsyncFunctionDef + ) and child.name.startswith("test_"): + names.append(_normalize_test_name(node.name, child.name)) # noqa: PERF401 + return names + + +def register_in_worker_suites( + namespace: dict[str, Any], + src_dir: Path, + *, + marks: dict[str, pytest.MarkDecorator] | None = None, + class_name: Callable[[str], str] | None = None, +) -> None: + """Expose each in-worker test as an individual host-side pytest test.""" + for module_path in sorted(src_dir.glob("test_*.py")): + suite = module_path.stem[len("test_") :] + generated_class_name = ( + class_name(suite) + if class_name + else "".join(part.title() for part in suite.split("_")) + ) + suite_cls = type( + f"Test{generated_class_name}", + (), + { + f"test_{name}": _make_test(suite, name) + for name in discover_test_names(module_path) + }, + ) + if marks and suite in marks: + suite_cls = marks[suite](suite_cls) + namespace[suite_cls.__name__] = suite_cls