diff --git a/CHANGELOG.md b/CHANGELOG.md index 79da33b..38046aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +## 0.0.45 + +* **Cooperative SIGTERM shutdown for plugin webservers**: plugin uvicorn servers now run under + a `GracefulServer` that signals a process-global cancellation event when SIGTERM/SIGINT is + received. Run-functions that declare a `cancellation_token` parameter receive a read-only token + they can poll between units of work and raise `PluginShutdown` to abort promptly; this maps to an + HTTP 503 shutdown-abort response. A finite default `timeout_graceful_shutdown` (30s, overridable + via `UVICORN_TIMEOUT_GRACEFUL_SHUTDOWN`) bounds uvicorn's drain window — previously unbounded. + Plugins that do not declare `cancellation_token` are unaffected. +* **Remove the inert SIGTERM-ignore signal patch**: 0.0.44 set `uvicorn.Server.install_signal_handlers` + to a SIGINT-only handler, but the pinned uvicorn (0.37) removed that method in favor of + `capture_signals()`, so the patch never took effect and plugins already handled SIGTERM normally. + It is removed to avoid a misleading mental model; prompt shutdown now comes from the finite + graceful-shutdown timeout plus the `GracefulServer` cancellation hook above. + ## 0.0.44 * **Ignore SIGTERM in plugin uvicorn Servers**: plugin webservers now keep diff --git a/pyproject.toml b/pyproject.toml index 4bb84aa..04f2f89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ lint = [ test = [ "pytest", "httpx", + "pytest-asyncio", "pytest-cov" ] diff --git a/test/api/test_api.py b/test/api/test_api.py index f8bca3c..60ef5a8 100644 --- a/test/api/test_api.py +++ b/test/api/test_api.py @@ -11,6 +11,10 @@ SourceIdentifiers, ) +from test.assets.cancellation_token_async import CancelAwareAsync +from test.assets.cancellation_token_asyncgen import CancelAwareAsyncGen +from test.assets.cancellation_token_sync import CancelAwareSync +from unstructured_platform_plugins.etl_uvicorn import shutdown from unstructured_platform_plugins.etl_uvicorn.api_generator import ( EtlApiException, UsageData, @@ -470,3 +474,89 @@ def test_streaming_unstructured_ingest_error_with_none_status_code(): "Async gen test UnstructuredIngestError with None status_code" in invoke_response.status_code_text ) + + +# ── CancellationToken / PluginShutdown tests ────────────────────────────────── + + +@pytest.fixture(autouse=True) +def _reset_shutdown(): + shutdown.reset_for_tests() + yield + shutdown.reset_for_tests() + + +@pytest.mark.parametrize("job_cls", [CancelAwareSync, CancelAwareAsync]) +def test_cancellation_token_not_in_invoke_schema(job_cls): + job = job_cls() + app = wrap_in_fastapi(func=job.run, plugin_id=job.id()) + client = TestClient(app) + schema = client.get("/schema").json() + assert "cancellation_token" not in schema["inputs"] + openapi = client.get("/openapi.json").json() + invoke_schema = str(openapi["paths"]["/invoke"]) + assert "cancellation_token" not in invoke_schema + + +@pytest.mark.parametrize("job_cls", [CancelAwareSync, CancelAwareAsync]) +def test_invoke_succeeds_when_not_shutting_down(job_cls): + job = job_cls() + app = wrap_in_fastapi(func=job.run, plugin_id=job.id()) + client = TestClient(app) + resp = client.post("/invoke", json={"value": 41}).json() + assert resp["status_code"] == 200 + assert resp["output"]["value"] == 42 + + +@pytest.mark.parametrize("job_cls", [CancelAwareSync, CancelAwareAsync]) +def test_invoke_returns_503_shutdown_abort_when_cancelled(job_cls): + job = job_cls() + app = wrap_in_fastapi(func=job.run, plugin_id=job.id()) + client = TestClient(app) + shutdown.request_shutdown() + resp = client.post("/invoke", json={"value": 41}).json() + assert resp["status_code"] == 503 + assert "shutdown" in (resp["status_code_text"] or "").lower() + + +def test_streaming_invoke_succeeds_when_not_shutting_down(): + """Happy-path: async-gen run func streams a 200 row when shutdown is not requested.""" + import json + + job = CancelAwareAsyncGen() + app = wrap_in_fastapi(func=job.run, plugin_id=job.id()) + client = TestClient(app) + + resp = client.post("/invoke", json={"value": 41}) + + assert resp.status_code == 200 + assert resp.headers["content-type"] == "application/x-ndjson" + + lines = resp.content.decode().strip().split("\n") + assert len(lines) == 1 + + row = InvokeResponse.model_validate(json.loads(lines[0])) + assert row.status_code == 200 + assert row.output == {"value": 42} + + +def test_streaming_invoke_returns_503_shutdown_abort_when_cancelled(): + """Streaming PluginShutdown handler: yields a 503 row and stops when shutdown is requested.""" + import json + + job = CancelAwareAsyncGen() + app = wrap_in_fastapi(func=job.run, plugin_id=job.id()) + client = TestClient(app) + + shutdown.request_shutdown() + resp = client.post("/invoke", json={"value": 41}) + + assert resp.status_code == 200 + assert resp.headers["content-type"] == "application/x-ndjson" + + lines = resp.content.decode().strip().split("\n") + assert len(lines) == 1 + + row = InvokeResponse.model_validate(json.loads(lines[0])) + assert row.status_code == 503 + assert "shutdown" in (row.status_code_text or "").lower() diff --git a/test/assets/cancellation_token_async.py b/test/assets/cancellation_token_async.py new file mode 100644 index 0000000..7a8c05b --- /dev/null +++ b/test/assets/cancellation_token_async.py @@ -0,0 +1,18 @@ +from typing import Optional + +from pydantic import BaseModel + +from unstructured_platform_plugins.etl_uvicorn.shutdown import CancellationToken + + +class Result(BaseModel): + value: int + + +class CancelAwareAsync: + def id(self) -> str: + return "cancel_aware_async" + + async def run(self, value: int, cancellation_token: CancellationToken) -> Optional[Result]: + cancellation_token.raise_if_cancelled() + return Result(value=value + 1) diff --git a/test/assets/cancellation_token_asyncgen.py b/test/assets/cancellation_token_asyncgen.py new file mode 100644 index 0000000..e386e69 --- /dev/null +++ b/test/assets/cancellation_token_asyncgen.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel + +from unstructured_platform_plugins.etl_uvicorn.shutdown import CancellationToken + + +class Result(BaseModel): + value: int + + +class CancelAwareAsyncGen: + def id(self) -> str: + return "cancel_aware_asyncgen" + + async def run(self, value: int, cancellation_token: CancellationToken) -> Result: + cancellation_token.raise_if_cancelled() + yield Result(value=value + 1) diff --git a/test/assets/cancellation_token_sync.py b/test/assets/cancellation_token_sync.py new file mode 100644 index 0000000..e8a36f5 --- /dev/null +++ b/test/assets/cancellation_token_sync.py @@ -0,0 +1,18 @@ +from typing import Optional + +from pydantic import BaseModel + +from unstructured_platform_plugins.etl_uvicorn.shutdown import CancellationToken + + +class Result(BaseModel): + value: int + + +class CancelAwareSync: + def id(self) -> str: + return "cancel_aware_sync" + + def run(self, value: int, cancellation_token: CancellationToken) -> Optional[Result]: + cancellation_token.raise_if_cancelled() + return Result(value=value + 1) diff --git a/test/test_main_cli.py b/test/test_main_cli.py new file mode 100644 index 0000000..c58f6f4 --- /dev/null +++ b/test/test_main_cli.py @@ -0,0 +1,34 @@ +from unittest.mock import patch + +from click.testing import CliRunner + +from unstructured_platform_plugins.etl_uvicorn.main import get_command +from unstructured_platform_plugins.etl_uvicorn.serve import DEFAULT_TIMEOUT_GRACEFUL_SHUTDOWN + + +def test_cli_uses_graceful_server_with_finite_timeout(): + captured = {} + + class _FakeServer: + def __init__(self, config): + captured["config"] = config + + def run(self): + captured["ran"] = True + + with ( + patch( + "unstructured_platform_plugins.etl_uvicorn.main.generate_fast_api", + return_value=lambda *a, **k: None, + ), + patch("unstructured_platform_plugins.etl_uvicorn.main.GracefulServer", _FakeServer), + ): + runner = CliRunner() + result = runner.invoke( + get_command(), + ["test.assets.hash_function:get_hash", "--host", "0.0.0.0", "--port", "8000"], + ) + + assert result.exit_code == 0, result.output + assert captured.get("ran") is True + assert captured["config"].timeout_graceful_shutdown == DEFAULT_TIMEOUT_GRACEFUL_SHUTDOWN diff --git a/test/test_serve.py b/test/test_serve.py new file mode 100644 index 0000000..d772d6b --- /dev/null +++ b/test/test_serve.py @@ -0,0 +1,57 @@ +import signal + +import pytest +from uvicorn.config import Config + +from unstructured_platform_plugins.etl_uvicorn import shutdown +from unstructured_platform_plugins.etl_uvicorn.serve import ( + DEFAULT_TIMEOUT_GRACEFUL_SHUTDOWN, + GracefulServer, +) + + +@pytest.fixture(autouse=True) +def _reset(): + shutdown.reset_for_tests() + yield + shutdown.reset_for_tests() + + +def _dummy_app(scope, receive, send): # minimal ASGI app + raise NotImplementedError + + +def test_handle_exit_sets_cancellation_event_and_delegates(): + server = GracefulServer(Config(_dummy_app)) + assert shutdown.is_shutting_down() is False + server.handle_exit(signal.SIGTERM, None) + assert shutdown.is_shutting_down() is True + # super().handle_exit must still run uvicorn's own shutdown bookkeeping + assert server.should_exit is True + + +def test_default_timeout_is_finite(): + assert isinstance(DEFAULT_TIMEOUT_GRACEFUL_SHUTDOWN, int) + assert DEFAULT_TIMEOUT_GRACEFUL_SHUTDOWN > 0 + + +def test_serve_constructs_graceful_server_with_passed_timeout(): + """serve() must build a GracefulServer whose Config carries the caller's timeout.""" + from unittest.mock import patch + + from unstructured_platform_plugins.etl_uvicorn.serve import serve + + captured = {} + + class _FakeServer: + def __init__(self, config): + captured["config"] = config + + def run(self): + captured["ran"] = True + + with patch("unstructured_platform_plugins.etl_uvicorn.serve.GracefulServer", _FakeServer): + serve(_dummy_app, port=9999, timeout_graceful_shutdown=7) + + assert captured.get("ran") is True + assert captured["config"].timeout_graceful_shutdown == 7 diff --git a/test/test_shutdown.py b/test/test_shutdown.py new file mode 100644 index 0000000..eb30f94 --- /dev/null +++ b/test/test_shutdown.py @@ -0,0 +1,45 @@ +import threading + +import pytest + +from unstructured_platform_plugins.etl_uvicorn import shutdown +from unstructured_platform_plugins.etl_uvicorn.shutdown import ( + CancellationToken, + PluginShutdown, +) + + +@pytest.fixture(autouse=True) +def _reset(): + shutdown.reset_for_tests() + yield + shutdown.reset_for_tests() + + +def test_token_not_cancelled_by_default(): + token = shutdown.get_cancellation_token() + assert token.cancelled is False + token.raise_if_cancelled() # no raise + + +def test_request_shutdown_flips_token_and_global(): + token = shutdown.get_cancellation_token() + assert shutdown.is_shutting_down() is False + shutdown.request_shutdown() + assert shutdown.is_shutting_down() is True + assert token.cancelled is True + with pytest.raises(PluginShutdown): + token.raise_if_cancelled() + + +def test_token_has_no_set_method(): + token = shutdown.get_cancellation_token() + assert not hasattr(token, "set") + + +def test_token_reads_live_event(): + event = threading.Event() + token = CancellationToken(event) + assert token.cancelled is False + event.set() + assert token.cancelled is True diff --git a/test/test_shutdown_cooperative.py b/test/test_shutdown_cooperative.py new file mode 100644 index 0000000..559a00a --- /dev/null +++ b/test/test_shutdown_cooperative.py @@ -0,0 +1,39 @@ +import asyncio +import threading + +import pytest + +from unstructured_platform_plugins.etl_uvicorn import shutdown +from unstructured_platform_plugins.etl_uvicorn.api_generator import invoke_func +from unstructured_platform_plugins.etl_uvicorn.shutdown import ( + CancellationToken, + PluginShutdown, +) + + +@pytest.fixture(autouse=True) +def _reset(): + shutdown.reset_for_tests() + yield + shutdown.reset_for_tests() + + +async def test_sync_runfunc_bails_promptly_when_cancelled(): + started = threading.Event() + + def blocking_run(cancellation_token: CancellationToken) -> str: + started.set() + # simulate a unit-of-work loop that polls between units + for _ in range(1000): + cancellation_token.raise_if_cancelled() + threading.Event().wait(0.01) + return "completed" + + token = shutdown.get_cancellation_token() + task = asyncio.ensure_future( + invoke_func(func=blocking_run, kwargs={"cancellation_token": token}) + ) + await asyncio.get_event_loop().run_in_executor(None, started.wait, 2.0) + shutdown.request_shutdown() + with pytest.raises(PluginShutdown): + await asyncio.wait_for(task, timeout=2.0) diff --git a/test/test_signal_handlers.py b/test/test_signal_handlers.py deleted file mode 100644 index 2832e93..0000000 --- a/test/test_signal_handlers.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Verify the etl_uvicorn module installs a SIGTERM-ignoring handler on uvicorn.Server.""" - -import asyncio -import signal - -import uvicorn - -# Importing the module applies the monkey-patch as a side effect. -from unstructured_platform_plugins.etl_uvicorn import main # noqa: F401 - - -def test_uvicorn_server_install_signal_handlers_is_patched(): - assert ( - uvicorn.Server.install_signal_handlers.__name__ - == "_install_signal_handlers_ignoring_sigterm" - ) - - -def test_install_signal_handlers_registers_sigint_only(): - async def _run() -> None: - config = uvicorn.Config(app="fake:app", lifespan="off") - server = uvicorn.Server(config=config) - server.install_signal_handlers() - loop = asyncio.get_running_loop() - try: - assert loop.remove_signal_handler(signal.SIGINT) is True - assert loop.remove_signal_handler(signal.SIGTERM) is False - finally: - try: - loop.remove_signal_handler(signal.SIGINT) - loop.remove_signal_handler(signal.SIGTERM) - except Exception: - pass - - asyncio.run(_run()) diff --git a/test/test_utils.py b/test/test_utils.py index f35328f..deaf497 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -8,6 +8,7 @@ from uvicorn.importer import import_from_string from unstructured_platform_plugins.etl_uvicorn import utils +from unstructured_platform_plugins.etl_uvicorn.api_generator import check_precheck_func def test_get_func_simple(): @@ -131,6 +132,15 @@ def fn(a: A, b: B, c: MyEnum, d: list, e: FileData) -> None: assert mapped_inputs == expected +def test_get_schema_dict_omits_cancellation_token(): + """get_schema_dict must not expose cancellation_token in inputs (affects plugin-id hash).""" + from test.assets.cancellation_token_sync import CancelAwareSync + + job = CancelAwareSync() + schema = utils.get_schema_dict(job.run) + assert "cancellation_token" not in schema["inputs"] + + def test_map_inputs_error(): def fn(a: FileData) -> None: pass @@ -139,3 +149,65 @@ def fn(a: FileData) -> None: with pytest.raises(ValueError): utils.map_inputs(func=fn, raw_inputs=inputs) + + +# --------------------------------------------------------------------------- +# check_precheck_func +# --------------------------------------------------------------------------- + + +def test_check_precheck_func_zero_args(): + """A precheck with no parameters is valid.""" + + def precheck(): + pass + + check_precheck_func(precheck) # must not raise + + +def test_check_precheck_func_usage_param(): + """A precheck that accepts `usage` is valid.""" + + def precheck(usage): + pass + + check_precheck_func(precheck) + + +def test_check_precheck_func_cancellation_token_param(): + """A precheck that accepts `cancellation_token` is valid.""" + + def precheck(cancellation_token): + pass + + check_precheck_func(precheck) + + +def test_check_precheck_func_bound_method_with_self(): + """Bound methods expose `self` in their signature; it must be silently skipped.""" + + class _Helper: + def precheck(self): + pass + + check_precheck_func(_Helper().precheck) + + +def test_check_precheck_func_rejects_unknown_param(): + """An unrecognised parameter name must raise ValueError.""" + + def precheck(some_unknown_param): + pass + + with pytest.raises(ValueError, match="unexpected precheck input"): + check_precheck_func(precheck) + + +def test_check_precheck_func_rejects_non_none_return_annotation(): + """A non-None return annotation must raise ValueError.""" + + def precheck() -> str: + return "oops" + + with pytest.raises(ValueError, match="no output should exist"): + check_precheck_func(precheck) diff --git a/unstructured_platform_plugins/__version__.py b/unstructured_platform_plugins/__version__.py index 3fbbe28..d8f2458 100644 --- a/unstructured_platform_plugins/__version__.py +++ b/unstructured_platform_plugins/__version__.py @@ -1 +1 @@ -__version__ = "0.0.44" # pragma: no cover +__version__ = "0.0.45" # pragma: no cover diff --git a/unstructured_platform_plugins/etl_uvicorn/api_generator.py b/unstructured_platform_plugins/etl_uvicorn/api_generator.py index 06e0074..247d115 100644 --- a/unstructured_platform_plugins/etl_uvicorn/api_generator.py +++ b/unstructured_platform_plugins/etl_uvicorn/api_generator.py @@ -18,6 +18,10 @@ from uvicorn.importer import import_from_string from unstructured_platform_plugins.etl_uvicorn.otel import get_metric_provider, get_trace_provider +from unstructured_platform_plugins.etl_uvicorn.shutdown import ( + PluginShutdown, + get_cancellation_token, +) from unstructured_platform_plugins.etl_uvicorn.utils import ( get_func, get_input_schema, @@ -72,12 +76,15 @@ async def invoke_func(func: Callable, kwargs: Optional[dict[str, Any]] = None) - def check_precheck_func(precheck_func: Callable): sig = inspect.signature(precheck_func) - inputs = sig.parameters.values() + allowed = {"usage", "cancellation_token"} + for name in sig.parameters: + if name == "self": + continue + if name not in allowed: + raise ValueError( + f"unexpected precheck input '{name}'; only {sorted(allowed)} are available" + ) outputs = sig.return_annotation - if len(inputs) == 1: - i = inputs[0] - if i.name != "usage" or i.annotation is list: - raise ValueError("the only input available for precheck is usage which must be a list") if outputs not in [None, sig.empty]: raise ValueError(f"no output should exist for precheck function, found: {outputs}") @@ -149,7 +156,9 @@ class InvokeResponse(BaseModel): output: Optional[response_type] = None message_channels: MessageChannels = Field(default_factory=MessageChannels) - input_schema = get_input_schema(func, omit=["usage", "filedata_meta", "message_channels"]) + input_schema = get_input_schema( + func, omit=["usage", "filedata_meta", "message_channels", "cancellation_token"] + ) input_schema_model = schema_to_base_model(input_schema) logging.getLogger("etl_uvicorn.fastapi") @@ -169,6 +178,8 @@ async def wrap_fn(func: Callable, kwargs: Optional[dict[str, Any]] = None) -> Re request_dict["message_channels"] = message_channels if "filedata_meta" in inspect.signature(func).parameters: request_dict["filedata_meta"] = filedata_meta + if "cancellation_token" in inspect.signature(func).parameters: + request_dict["cancellation_token"] = get_cancellation_token() try: if inspect.isasyncgenfunction(func): # Stream response if function is an async generator @@ -188,6 +199,21 @@ async def _stream_response(): ).model_dump_json() + "\n" ) + except PluginShutdown: + logger.info("plugin aborted in-flight streaming work on shutdown") + yield ( + InvokeResponse( + usage=usage, + message_channels=message_channels, + filedata_meta=filedata_meta_model.model_validate( + filedata_meta.model_dump() + ), + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + status_code_text="plugin shutdown: in-flight work aborted", + ).model_dump_json() + + "\n" + ) + return except Exception as e: logger.error(f"Failure streaming response: {e}", exc_info=True) yield ( @@ -215,6 +241,16 @@ async def _stream_response(): output=output, file_data=request_dict.get("file_data", None), ) + except PluginShutdown: + logger.info("plugin aborted in-flight work on shutdown") + return InvokeResponse( + usage=usage, + message_channels=message_channels, + filedata_meta=filedata_meta_model.model_validate(filedata_meta.model_dump()), + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + status_code_text="plugin shutdown: in-flight work aborted", + file_data=request_dict.get("file_data", None), + ) except HTTPException as exc: logger.error( f"HTTPException: {exc.detail} (status_code={exc.status_code})", exc_info=True diff --git a/unstructured_platform_plugins/etl_uvicorn/main.py b/unstructured_platform_plugins/etl_uvicorn/main.py index 600e0c1..37d2123 100644 --- a/unstructured_platform_plugins/etl_uvicorn/main.py +++ b/unstructured_platform_plugins/etl_uvicorn/main.py @@ -1,32 +1,16 @@ -import asyncio -import signal -import threading +import sys from dataclasses import dataclass, field from typing import IO, Any, Optional import click -import uvicorn from uvicorn.config import LOGGING_CONFIG, Config, RawConfigParser -from uvicorn.main import main, run +from uvicorn.main import main from unstructured_platform_plugins.etl_uvicorn.api_generator import generate_fast_api - - -def _install_signal_handlers_ignoring_sigterm(self: uvicorn.Server) -> None: - # uvicorn's default load-sheds 504 on SIGTERM, which races with controllers - # trying to drain in-flight work during pod shutdown. Plugin webservers are - # expected to outlive their controller container so SIGKILL — not SIGTERM — - # ends the process. SIGINT is preserved so local Ctrl-C still works. - if threading.current_thread() is not threading.main_thread(): - return - try: - loop = asyncio.get_event_loop() - loop.add_signal_handler(signal.SIGINT, self.handle_exit, signal.SIGINT, None) - except NotImplementedError: - signal.signal(signal.SIGINT, self.handle_exit) - - -uvicorn.Server.install_signal_handlers = _install_signal_handlers_ignoring_sigterm +from unstructured_platform_plugins.etl_uvicorn.serve import ( + DEFAULT_TIMEOUT_GRACEFUL_SHUTDOWN, + GracefulServer, +) @dataclass @@ -74,9 +58,16 @@ def api_wrapper( precheck_str=precheck_app, precheck_method=precheck_app_method, ) - # Explicitly map values that are manipulated in the original - # call to run(), preventing **kwargs reference - run( + # `app_dir` and `version` are CLI-only params that uvicorn.Config does not accept. + # `app_dir` adjusts sys.path; `version` is --version flag metadata only. + app_dir = kwargs.pop("app_dir", None) + kwargs.pop("version", None) + if app_dir is not None: + sys.path.insert(0, app_dir) + + if kwargs.get("timeout_graceful_shutdown") is None: + kwargs["timeout_graceful_shutdown"] = DEFAULT_TIMEOUT_GRACEFUL_SHUTDOWN + config = Config( fastapi_app, log_config=LOGGING_CONFIG if log_config is None else log_config, reload_dirs=reload_dirs or None, @@ -85,9 +76,11 @@ def api_wrapper( headers=[header.split(":", 1) for header in headers], # type: ignore[misc] **kwargs, ) + # reload / multi-worker supervisors are intentionally unsupported for plugin containers + GracefulServer(config).run() cmd = api_wrapper - cmd.params = main.params + cmd.params = list(main.params) cmd.params.extend( [ click.Option( diff --git a/unstructured_platform_plugins/etl_uvicorn/serve.py b/unstructured_platform_plugins/etl_uvicorn/serve.py new file mode 100644 index 0000000..c228e50 --- /dev/null +++ b/unstructured_platform_plugins/etl_uvicorn/serve.py @@ -0,0 +1,44 @@ +import os +from typing import Any, Optional + +import uvicorn + +from unstructured_platform_plugins.etl_uvicorn.shutdown import request_shutdown + +DEFAULT_TIMEOUT_GRACEFUL_SHUTDOWN: int = int(os.getenv("UVICORN_TIMEOUT_GRACEFUL_SHUTDOWN", "30")) + + +class GracefulServer(uvicorn.Server): + """uvicorn Server that signals the process cancellation event on shutdown. + + ``handle_exit`` is uvicorn's single SIGINT/SIGTERM chokepoint (installed via + ``capture_signals`` -> ``signal.signal``). We set the cancellation event + before delegating so threadpool-bound plugin work can cooperatively stop. + """ + + def handle_exit(self, sig: int, frame: Any) -> None: + request_shutdown() + super().handle_exit(sig, frame) + + +def serve( + app: Any, + *, + host: str = "127.0.0.1", + port: int = 8000, + log_level: Optional[Any] = None, + log_config: Optional[Any] = None, + timeout_graceful_shutdown: Optional[int] = DEFAULT_TIMEOUT_GRACEFUL_SHUTDOWN, + **kwargs: Any, +) -> None: + """Launch ``app`` under a GracefulServer with a finite graceful-shutdown timeout.""" + config = uvicorn.Config( + app, + host=host, + port=port, + log_level=log_level, + log_config=log_config, + timeout_graceful_shutdown=timeout_graceful_shutdown, + **kwargs, + ) + GracefulServer(config).run() diff --git a/unstructured_platform_plugins/etl_uvicorn/shutdown.py b/unstructured_platform_plugins/etl_uvicorn/shutdown.py new file mode 100644 index 0000000..c4150e8 --- /dev/null +++ b/unstructured_platform_plugins/etl_uvicorn/shutdown.py @@ -0,0 +1,58 @@ +import logging +import threading + +logger = logging.getLogger("uvicorn.error") + +_cancellation_event = threading.Event() + + +class PluginShutdown(Exception): + """Raised inside a plugin run-loop to abort in-flight work on SIGTERM. + + Caught by the etl_uvicorn invoke wrapper and mapped to a shutdown-abort + response; must NOT be treated as a plugin failure. + """ + + +class CancellationToken: + """Read-only view of the process shutdown signal handed to plugin run-funcs. + + Backed by a process-global event set by GracefulServer.handle_exit on + SIGTERM/SIGINT. ``.set()`` is intentionally not exposed so plugins cannot + self-trigger shutdown. + """ + + def __init__(self, event: threading.Event) -> None: + self._event = event + + @property + def cancelled(self) -> bool: + return self._event.is_set() + + def raise_if_cancelled(self) -> None: + if self._event.is_set(): + raise PluginShutdown("shutdown requested; aborting in-flight work") + + +def request_shutdown() -> None: + if not _cancellation_event.is_set(): + logger.info("shutdown requested; signalling in-flight plugin work to stop") + _cancellation_event.set() + + +def is_shutting_down() -> bool: + return _cancellation_event.is_set() + + +def get_cancellation_token() -> CancellationToken: + return CancellationToken(_cancellation_event) + + +def cancellation_dependency() -> CancellationToken: + """FastAPI dependency for direct-FastAPI plugins to consult shutdown state.""" + return get_cancellation_token() + + +def reset_for_tests() -> None: + """Clear the global event. Tests only.""" + _cancellation_event.clear() diff --git a/unstructured_platform_plugins/etl_uvicorn/utils.py b/unstructured_platform_plugins/etl_uvicorn/utils.py index e71576a..609c6f6 100644 --- a/unstructured_platform_plugins/etl_uvicorn/utils.py +++ b/unstructured_platform_plugins/etl_uvicorn/utils.py @@ -81,7 +81,7 @@ def get_output_schema(func: Callable) -> dict: return response_to_json_schema(get_output_sig(func)) -def get_schema_dict(func, omit: list[str] = ["usage"]) -> dict: +def get_schema_dict(func, omit: list[str] = ["usage", "cancellation_token"]) -> dict: return { "inputs": get_input_schema(func, omit=omit), "outputs": get_output_schema(func), diff --git a/uv.lock b/uv.lock index 18c13ae..3d6de17 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.13'", @@ -42,6 +42,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/d1/69d02ce34caddb0a7ae088b84c356a625a93cd4ff57b2f97644c03fad905/asgiref-3.9.2-py3-none-any.whl", hash = "sha256:0b61526596219d70396548fc003635056856dba5d0d086f86476f10b33c75960", size = 23788, upload-time = "2025-09-23T15:00:53.627Z" }, ] +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + [[package]] name = "backports-tarfile" version = "1.2.0" @@ -87,43 +96,31 @@ sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8 wheels = [ { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, - { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, - { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, - { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, - { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, @@ -332,10 +329,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/ee/ca6cc9df7118f2fcd142c76b1da0f14340d77518c05b1ebfbbabca6b9e7d/cryptography-46.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9873bf7c1f2a6330bdfe8621e7ce64b725784f9f0c3a6a55c3047af5849f920e", size = 4572393, upload-time = "2025-09-17T00:08:41.663Z" }, { url = "https://files.pythonhosted.org/packages/7f/a3/0f5296f63815d8e985922b05c31f77ce44787b3127a67c0b7f70f115c45f/cryptography-46.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0dfb7c88d4462a0cfdd0d87a3c245a7bc3feb59de101f6ff88194f740f72eda6", size = 4308400, upload-time = "2025-09-17T00:08:43.559Z" }, { url = "https://files.pythonhosted.org/packages/5d/8c/74fcda3e4e01be1d32775d5b4dd841acaac3c1b8fa4d0774c7ac8d52463d/cryptography-46.0.1-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e22801b61613ebdebf7deb18b507919e107547a1d39a3b57f5f855032dd7cfb8", size = 4015786, upload-time = "2025-09-17T00:08:45.758Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b8/85d23287baeef273b0834481a3dd55bbed3a53587e3b8d9f0898235b8f91/cryptography-46.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:757af4f6341ce7a1e47c326ca2a81f41d236070217e5fbbad61bbfe299d55d28", size = 4982606, upload-time = "2025-09-17T00:08:47.602Z" }, { url = "https://files.pythonhosted.org/packages/e5/d3/de61ad5b52433b389afca0bc70f02a7a1f074651221f599ce368da0fe437/cryptography-46.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f7a24ea78de345cfa7f6a8d3bde8b242c7fac27f2bd78fa23474ca38dfaeeab9", size = 4604234, upload-time = "2025-09-17T00:08:49.879Z" }, { url = "https://files.pythonhosted.org/packages/dc/1f/dbd4d6570d84748439237a7478d124ee0134bf166ad129267b7ed8ea6d22/cryptography-46.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9e8776dac9e660c22241b6587fae51a67b4b0147daa4d176b172c3ff768ad736", size = 4307669, upload-time = "2025-09-17T00:08:52.321Z" }, - { url = "https://files.pythonhosted.org/packages/ec/fd/ca0a14ce7f0bfe92fa727aacaf2217eb25eb7e4ed513b14d8e03b26e63ed/cryptography-46.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9f40642a140c0c8649987027867242b801486865277cbabc8c6059ddef16dc8b", size = 4947579, upload-time = "2025-09-17T00:08:54.697Z" }, { url = "https://files.pythonhosted.org/packages/89/6b/09c30543bb93401f6f88fce556b3bdbb21e55ae14912c04b7bf355f5f96c/cryptography-46.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:449ef2b321bec7d97ef2c944173275ebdab78f3abdd005400cc409e27cd159ab", size = 4603669, upload-time = "2025-09-17T00:08:57.16Z" }, { url = "https://files.pythonhosted.org/packages/23/9a/38cb01cb09ce0adceda9fc627c9cf98eb890fc8d50cacbe79b011df20f8a/cryptography-46.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2dd339ba3345b908fa3141ddba4025568fa6fd398eabce3ef72a29ac2d73ad75", size = 4435828, upload-time = "2025-09-17T00:08:59.606Z" }, { url = "https://files.pythonhosted.org/packages/0f/53/435b5c36a78d06ae0bef96d666209b0ecd8f8181bfe4dda46536705df59e/cryptography-46.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7411c910fb2a412053cf33cfad0153ee20d27e256c6c3f14d7d7d1d9fec59fd5", size = 4709553, upload-time = "2025-09-17T00:09:01.832Z" }, @@ -343,10 +338,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b7/d4f848aee24ecd1be01db6c42c4a270069a4f02a105d9c57e143daf6cf0f/cryptography-46.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9f2c4cc63be3ef43c0221861177cee5d14b505cd4d4599a89e2cd273c4d3542a", size = 4545619, upload-time = "2025-09-17T00:09:15.397Z" }, { url = "https://files.pythonhosted.org/packages/44/a5/42fedefc754fd1901e2d95a69815ea4ec8a9eed31f4c4361fcab80288661/cryptography-46.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:41c281a74df173876da1dc9a9b6953d387f06e3d3ed9284e3baae3ab3f40883a", size = 4299160, upload-time = "2025-09-17T00:09:17.155Z" }, { url = "https://files.pythonhosted.org/packages/86/a1/cd21174f56e769c831fbbd6399a1b7519b0ff6280acec1b826d7b072640c/cryptography-46.0.1-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0a17377fa52563d730248ba1f68185461fff36e8bc75d8787a7dd2e20a802b7a", size = 3994491, upload-time = "2025-09-17T00:09:18.971Z" }, - { url = "https://files.pythonhosted.org/packages/8d/2f/a8cbfa1c029987ddc746fd966711d4fa71efc891d37fbe9f030fe5ab4eec/cryptography-46.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:0d1922d9280e08cde90b518a10cd66831f632960a8d08cb3418922d83fce6f12", size = 4960157, upload-time = "2025-09-17T00:09:20.923Z" }, { url = "https://files.pythonhosted.org/packages/67/ae/63a84e6789e0d5a2502edf06b552bcb0fa9ff16147265d5c44a211942abe/cryptography-46.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:af84e8e99f1a82cea149e253014ea9dc89f75b82c87bb6c7242203186f465129", size = 4577263, upload-time = "2025-09-17T00:09:23.356Z" }, { url = "https://files.pythonhosted.org/packages/ef/8f/1b9fa8e92bd9cbcb3b7e1e593a5232f2c1e6f9bd72b919c1a6b37d315f92/cryptography-46.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ef648d2c690703501714588b2ba640facd50fd16548133b11b2859e8655a69da", size = 4298703, upload-time = "2025-09-17T00:09:25.566Z" }, - { url = "https://files.pythonhosted.org/packages/c3/af/bb95db070e73fea3fae31d8a69ac1463d89d1c084220f549b00dd01094a8/cryptography-46.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:e94eb5fa32a8a9f9bf991f424f002913e3dd7c699ef552db9b14ba6a76a6313b", size = 4926363, upload-time = "2025-09-17T00:09:27.451Z" }, { url = "https://files.pythonhosted.org/packages/f5/3b/d8fb17ffeb3a83157a1cc0aa5c60691d062aceecba09c2e5e77ebfc1870c/cryptography-46.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:534b96c0831855e29fc3b069b085fd185aa5353033631a585d5cd4dd5d40d657", size = 4576958, upload-time = "2025-09-17T00:09:29.924Z" }, { url = "https://files.pythonhosted.org/packages/d9/46/86bc3a05c10c8aa88c8ae7e953a8b4e407c57823ed201dbcba55c4d655f4/cryptography-46.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9b55038b5c6c47559aa33626d8ecd092f354e23de3c6975e4bb205df128a2a0", size = 4422507, upload-time = "2025-09-17T00:09:32.222Z" }, { url = "https://files.pythonhosted.org/packages/a8/4e/387e5a21dfd2b4198e74968a541cfd6128f66f8ec94ed971776e15091ac3/cryptography-46.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ec13b7105117dbc9afd023300fb9954d72ca855c274fe563e72428ece10191c0", size = 4683964, upload-time = "2025-09-17T00:09:34.118Z" }, @@ -354,10 +347,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/67/65dc233c1ddd688073cf7b136b06ff4b84bf517ba5529607c9d79720fc67/cryptography-46.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:341fb7a26bc9d6093c1b124b9f13acc283d2d51da440b98b55ab3f79f2522ead", size = 4562369, upload-time = "2025-09-17T00:09:47.601Z" }, { url = "https://files.pythonhosted.org/packages/17/db/d64ae4c6f4e98c3dac5bf35dd4d103f4c7c345703e43560113e5e8e31b2b/cryptography-46.0.1-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6ef1488967e729948d424d09c94753d0167ce59afba8d0f6c07a22b629c557b2", size = 4302126, upload-time = "2025-09-17T00:09:49.335Z" }, { url = "https://files.pythonhosted.org/packages/3d/19/5f1eea17d4805ebdc2e685b7b02800c4f63f3dd46cfa8d4c18373fea46c8/cryptography-46.0.1-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7823bc7cdf0b747ecfb096d004cc41573c2f5c7e3a29861603a2871b43d3ef32", size = 4009431, upload-time = "2025-09-17T00:09:51.239Z" }, - { url = "https://files.pythonhosted.org/packages/81/b5/229ba6088fe7abccbfe4c5edb96c7a5ad547fac5fdd0d40aa6ea540b2985/cryptography-46.0.1-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:f736ab8036796f5a119ff8211deda416f8c15ce03776db704a7a4e17381cb2ef", size = 4980739, upload-time = "2025-09-17T00:09:54.181Z" }, { url = "https://files.pythonhosted.org/packages/3a/9c/50aa38907b201e74bc43c572f9603fa82b58e831bd13c245613a23cff736/cryptography-46.0.1-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:e46710a240a41d594953012213ea8ca398cd2448fbc5d0f1be8160b5511104a0", size = 4592289, upload-time = "2025-09-17T00:09:56.731Z" }, { url = "https://files.pythonhosted.org/packages/5a/33/229858f8a5bb22f82468bb285e9f4c44a31978d5f5830bb4ea1cf8a4e454/cryptography-46.0.1-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:84ef1f145de5aee82ea2447224dc23f065ff4cc5791bb3b506615957a6ba8128", size = 4301815, upload-time = "2025-09-17T00:09:58.548Z" }, - { url = "https://files.pythonhosted.org/packages/52/cb/b76b2c87fbd6ed4a231884bea3ce073406ba8e2dae9defad910d33cbf408/cryptography-46.0.1-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9394c7d5a7565ac5f7d9ba38b2617448eba384d7b107b262d63890079fad77ca", size = 4943251, upload-time = "2025-09-17T00:10:00.475Z" }, { url = "https://files.pythonhosted.org/packages/94/0f/f66125ecf88e4cb5b8017ff43f3a87ede2d064cb54a1c5893f9da9d65093/cryptography-46.0.1-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ed957044e368ed295257ae3d212b95456bd9756df490e1ac4538857f67531fcc", size = 4591247, upload-time = "2025-09-17T00:10:02.874Z" }, { url = "https://files.pythonhosted.org/packages/f6/22/9f3134ae436b63b463cfdf0ff506a0570da6873adb4bf8c19b8a5b4bac64/cryptography-46.0.1-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f7de12fa0eee6234de9a9ce0ffcfa6ce97361db7a50b09b65c63ac58e5f22fc7", size = 4428534, upload-time = "2025-09-17T00:10:04.994Z" }, { url = "https://files.pythonhosted.org/packages/89/39/e6042bcb2638650b0005c752c38ea830cbfbcbb1830e4d64d530000aa8dc/cryptography-46.0.1-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7fab1187b6c6b2f11a326f33b036f7168f5b996aedd0c059f9738915e4e8f53a", size = 4699541, upload-time = "2025-09-17T00:10:06.925Z" }, @@ -1031,6 +1022,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + [[package]] name = "pytest-cov" version = "7.0.0" @@ -1347,6 +1352,7 @@ release = [ test = [ { name = "httpx" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, ] @@ -1372,6 +1378,7 @@ release = [ test = [ { name = "httpx" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, ]