From 8cce03c555b6d38c50e7f91b881def64bb01f201 Mon Sep 17 00:00:00 2001 From: traoreera Date: Mon, 28 Sep 2026 15:11:52 +0000 Subject: [PATCH 01/15] =?UTF-8?q?chore(deps):=20d=C3=A9pendances=20backend?= =?UTF-8?q?=20en=20extras=20optionnels,=20versions=20rafra=C3=AEchies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit ligne par ligne contre l'usage réel dans xcore/ (ServiceContainer importe déjà chaque service en lazy, gardé par sa config — voir services/container.py) : - pyproject.toml : dependencies réduit au strict noyau (fastapi[standard], pydantic, pyyaml, apscheduler, opentelemetry-api/sdk — tous chargés sans condition au boot, même zero-config). Tout le reste (sqlalchemy/drivers, redis, celery, alembic, exporteur OTLP, python-dotenv) n'était utilisé que si le service correspondant est configuré — déplacé vers des extras : [postgres], [sqlite], [db], [migrations], [redis], [worker], [tracing], [dotenv], [metrics], [all] - BREAKING packaging : `pip install XCoreRuntime` seul n'installe plus ces backends. Voir CHANGELOG.md pour la liste complète + la migration. - prometheus-client était importé par du code noyau (metrics.py, xcore/__init__.py) mais n'existait qu'en dev dependencies — jamais disponible en install de prod. Corrigé via l'extra [metrics]. - sqlalchemy[asyncio] explicite dans chaque extra DB — dépendait silencieusement de aiosqlite pour fournir greenlet en transitif, ce qui cassait une install postgres-only. - Versions rafraîchies : fastapi 0.135→0.141, pydantic 2.11→2.13, sqlalchemy 2.0→2.1, redis borne haute 8→9, apscheduler→3.11.3, opentelemetry-*→1.45, alembic→1.20, dotenv→1.2, psycopg2→2.9.13, prometheus-client 0.25→0.26. - Tous les extras dupliqués dans [tool.poetry.group.dev.dependencies] : la CI fait `poetry install --with dev` sans --extras, donc les tests qui exercent ces backends ont besoin des paquets là aussi. - Version : 2.6.0 → 2.7.0 (packaging change, potentiellement cassant pour l'install par défaut). 1539 tests passants, 1 échec pré-existant non lié (bug Studio hors scope). --- CHANGELOG.md | 18 ++ doc/changelog.md | 18 ++ poetry.lock | 682 ++++++++++++++++++++++++------------------- pyproject.toml | 99 +++++-- xcore/__version__.py | 4 +- 5 files changed, 502 insertions(+), 319 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfffe57..a4c3f8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.7.0] - 2026-09-28 + +### Changed +- **BREAKING (packaging): most backend-specific dependencies moved from hard requirements to opt-in extras.** A plain `pip install XCoreRuntime` no longer pulls in SQLAlchemy, database drivers, Redis, Celery, Alembic, the OTLP exporter, or `python-dotenv`. Each one was audited against actual usage in `xcore/` — `ServiceContainer`'s per-service providers (`xcore/services/container.py`) already import these lazily, only when the matching `services.*` config entry is present — and moved into a matching extra if it isn't needed for `import xcore` or the zero-config boot path: + - `XCoreRuntime[postgres]` — SQLAlchemy (`[asyncio]`) + `psycopg2`, for `postgresql://` URLs in `services.databases` + - `XCoreRuntime[sqlite]` — SQLAlchemy (`[asyncio]`) + `aiosqlite`, for `sqlite+aiosqlite://` URLs + - `XCoreRuntime[db]` — both of the above combined + - `XCoreRuntime[migrations]` — Alembic, only used by `MigrationRunner` (already imported on demand, with a clear `ImportError` message if missing) + - `XCoreRuntime[redis]` — for `services.cache`/`services.scheduler` `backend: redis` or `tiered` (the default `memory` backend needs none of it) + - `XCoreRuntime[worker]` — Celery, only instantiated when `services.xworker.enabled: true` (`False` by default) + - `XCoreRuntime[tracing]` — the OTLP/HTTP exporter, only imported when `observability.tracing.endpoint` is set (console export, already in core, is the default) + - `XCoreRuntime[dotenv]` — `.env` loading, already gracefully optional in the code (`try`/`except ImportError`) + - `XCoreRuntime[metrics]` — `prometheus-client`, for `observability.metrics.backend: prometheus`. This closes a real gap: the package was imported by core runtime code (`kernel/observability/metrics.py`, `xcore/__init__.py`) but previously only listed under dev dependencies — a production install could never actually get it. + - `XCoreRuntime[all]` — everything above, plus `sdk`/`xcli`/`cpp` + + **If you relied on a bare `pip install XCoreRuntime` for a working database, Redis cache/scheduler, Celery worker, migrations, OTLP export, `.env` loading, or Prometheus metrics, add the matching extra(s).** `apscheduler` stays in core: `SchedulerConfig.enabled` defaults to `True`, so the scheduler runs out of the box (in-memory backend) even with zero configuration — same for `opentelemetry-api`/`-sdk`, imported unconditionally at module load by `kernel/observability/tracing.py`. +- **Dependency versions refreshed**: `fastapi[standard]` 0.135→0.141, `pydantic` 2.11→2.13, `sqlalchemy` 2.0→2.1 (now pinned with `[asyncio]` in every DB extra — it was previously relying on `aiosqlite` to pull in `greenlet` transitively, which silently broke a Postgres-only install), `redis[hiredis]` upper bound raised 8→9, `apscheduler` →3.11.3, `opentelemetry-api`/`-sdk`/`-exporter-otlp-proto-http` 1.27→1.45, `alembic` →1.20, `python-dotenv` →1.2, `psycopg2` →2.9.13, `prometheus-client` 0.25→0.26 (dev dependency and new `metrics` extra aligned to the same constraint — `poetry lock` rejects mismatched ones for the same package). All backend extras are duplicated into `[tool.poetry.group.dev.dependencies]` so `poetry install --with dev` (what CI runs, without `--extras`) still exercises every backend in tests. + ## [2.6.0] - 2026-09-28 ### Added diff --git a/doc/changelog.md b/doc/changelog.md index cfffe57..a4c3f8d 100644 --- a/doc/changelog.md +++ b/doc/changelog.md @@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.7.0] - 2026-09-28 + +### Changed +- **BREAKING (packaging): most backend-specific dependencies moved from hard requirements to opt-in extras.** A plain `pip install XCoreRuntime` no longer pulls in SQLAlchemy, database drivers, Redis, Celery, Alembic, the OTLP exporter, or `python-dotenv`. Each one was audited against actual usage in `xcore/` — `ServiceContainer`'s per-service providers (`xcore/services/container.py`) already import these lazily, only when the matching `services.*` config entry is present — and moved into a matching extra if it isn't needed for `import xcore` or the zero-config boot path: + - `XCoreRuntime[postgres]` — SQLAlchemy (`[asyncio]`) + `psycopg2`, for `postgresql://` URLs in `services.databases` + - `XCoreRuntime[sqlite]` — SQLAlchemy (`[asyncio]`) + `aiosqlite`, for `sqlite+aiosqlite://` URLs + - `XCoreRuntime[db]` — both of the above combined + - `XCoreRuntime[migrations]` — Alembic, only used by `MigrationRunner` (already imported on demand, with a clear `ImportError` message if missing) + - `XCoreRuntime[redis]` — for `services.cache`/`services.scheduler` `backend: redis` or `tiered` (the default `memory` backend needs none of it) + - `XCoreRuntime[worker]` — Celery, only instantiated when `services.xworker.enabled: true` (`False` by default) + - `XCoreRuntime[tracing]` — the OTLP/HTTP exporter, only imported when `observability.tracing.endpoint` is set (console export, already in core, is the default) + - `XCoreRuntime[dotenv]` — `.env` loading, already gracefully optional in the code (`try`/`except ImportError`) + - `XCoreRuntime[metrics]` — `prometheus-client`, for `observability.metrics.backend: prometheus`. This closes a real gap: the package was imported by core runtime code (`kernel/observability/metrics.py`, `xcore/__init__.py`) but previously only listed under dev dependencies — a production install could never actually get it. + - `XCoreRuntime[all]` — everything above, plus `sdk`/`xcli`/`cpp` + + **If you relied on a bare `pip install XCoreRuntime` for a working database, Redis cache/scheduler, Celery worker, migrations, OTLP export, `.env` loading, or Prometheus metrics, add the matching extra(s).** `apscheduler` stays in core: `SchedulerConfig.enabled` defaults to `True`, so the scheduler runs out of the box (in-memory backend) even with zero configuration — same for `opentelemetry-api`/`-sdk`, imported unconditionally at module load by `kernel/observability/tracing.py`. +- **Dependency versions refreshed**: `fastapi[standard]` 0.135→0.141, `pydantic` 2.11→2.13, `sqlalchemy` 2.0→2.1 (now pinned with `[asyncio]` in every DB extra — it was previously relying on `aiosqlite` to pull in `greenlet` transitively, which silently broke a Postgres-only install), `redis[hiredis]` upper bound raised 8→9, `apscheduler` →3.11.3, `opentelemetry-api`/`-sdk`/`-exporter-otlp-proto-http` 1.27→1.45, `alembic` →1.20, `python-dotenv` →1.2, `psycopg2` →2.9.13, `prometheus-client` 0.25→0.26 (dev dependency and new `metrics` extra aligned to the same constraint — `poetry lock` rejects mismatched ones for the same package). All backend extras are duplicated into `[tool.poetry.group.dev.dependencies]` so `poetry install --with dev` (what CI runs, without `--extras`) still exercises every backend in tests. + ## [2.6.0] - 2026-09-28 ### Added diff --git a/poetry.lock b/poetry.lock index 54fe27b..d623b63 100644 --- a/poetry.lock +++ b/poetry.lock @@ -176,11 +176,12 @@ version = "0.22.1" description = "asyncio bridge to the standard sqlite3 module" optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb"}, {file = "aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650"}, ] +markers = {main = "extra == \"sqlite\" or extra == \"db\" or extra == \"all\""} [package.extras] dev = ["attribution (==1.8.0)", "black (==25.11.0)", "build (>=1.2)", "coverage[toml] (==7.10.7)", "flake8 (==7.3.0)", "flake8-bugbear (==24.12.12)", "flit (==3.12.0)", "mypy (==1.19.0)", "ufmt (==2.8.0)", "usort (==1.0.8.post1)"] @@ -188,19 +189,20 @@ docs = ["sphinx (==8.1.3)", "sphinx-mdinclude (==0.6.2)"] [[package]] name = "alembic" -version = "1.18.5" +version = "1.20.0" description = "A database migration tool for SQLAlchemy." optional = false python-versions = ">=3.10" -groups = ["main"] +groups = ["main", "dev"] files = [ - {file = "alembic-1.18.5-py3-none-any.whl", hash = "sha256:06d8ba9d04558022f5395e9317de03d270f3dced49cee01f89fe7a13c26f14bc"}, - {file = "alembic-1.18.5.tar.gz", hash = "sha256:1554982221dd17e9a749b53902407578eb305e453f71999e8c7f0a48389fff8e"}, + {file = "alembic-1.20.0-py3-none-any.whl", hash = "sha256:77eb101048d95f982c0353e9233404889dcd7a6fc244c107836c0e2fc9cf7d9d"}, + {file = "alembic-1.20.0.tar.gz", hash = "sha256:db505480647bc60386c5369402f4a57a506b7539c9e9ef5e270d45cbbe4939bf"}, ] +markers = {main = "extra == \"migrations\" or extra == \"all\" or extra == \"xcli\""} [package.dependencies] Mako = "*" -SQLAlchemy = ">=1.4.23" +SQLAlchemy = ">=2.0" typing-extensions = ">=4.12" [package.extras] @@ -212,11 +214,12 @@ version = "5.3.1" description = "Low-level AMQP client for Python (fork of amqplib)." optional = false python-versions = ">=3.6" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2"}, {file = "amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432"}, ] +markers = {main = "extra == \"worker\" or extra == \"all\""} [package.dependencies] vine = ">=5.0.0,<6.0.0" @@ -399,11 +402,12 @@ version = "4.2.4" description = "Python multiprocessing fork with improvements and bugfixes" optional = false python-versions = ">=3.7" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5"}, {file = "billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f"}, ] +markers = {main = "extra == \"worker\" or extra == \"all\""} [[package]] name = "black" @@ -462,11 +466,12 @@ version = "5.6.3" description = "Distributed Task Queue." optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "celery-5.6.3-py3-none-any.whl", hash = "sha256:0808f42f80909c4d5833202360ffafb2a4f83f4d8e23e1285d926610e9a7afa6"}, {file = "celery-5.6.3.tar.gz", hash = "sha256:177006bd2054b882e9f01be59abd8529e88879ef50d7918a7050c5a9f4e12912"}, ] +markers = {main = "extra == \"worker\" or extra == \"all\""} [package.dependencies] billiard = ">=4.2.1,<5.0" @@ -544,7 +549,7 @@ version = "3.4.7" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" -groups = ["main", "docs"] +groups = ["docs"] files = [ {file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"}, {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"}, @@ -698,11 +703,12 @@ version = "0.3.1" description = "Enables git-like *did-you-mean* feature in click" optional = false python-versions = ">=3.6.2" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c"}, {file = "click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463"}, ] +markers = {main = "extra == \"worker\" or extra == \"all\""} [package.dependencies] click = ">=7" @@ -713,11 +719,12 @@ version = "1.1.1.2" description = "An extension module for click to enable registering CLI commands via setuptools entry-points." optional = false python-versions = "*" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6"}, {file = "click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261"}, ] +markers = {main = "extra == \"worker\" or extra == \"all\""} [package.dependencies] click = ">=4.0" @@ -731,11 +738,12 @@ version = "0.3.0" description = "REPL plugin for Click" optional = false python-versions = ">=3.6" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9"}, {file = "click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812"}, ] +markers = {main = "extra == \"worker\" or extra == \"all\""} [package.dependencies] click = ">=7.0" @@ -950,20 +958,20 @@ tzdata = "*" [[package]] name = "fastapi" -version = "0.139.0" +version = "0.141.1" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189"}, - {file = "fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145"}, + {file = "fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3"}, + {file = "fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1"}, ] [package.dependencies] annotated-doc = ">=0.0.2" email-validator = {version = ">=2.0.0", optional = true, markers = "extra == \"standard\""} -fastapi-cli = {version = ">=0.0.8", extras = ["standard"], optional = true, markers = "extra == \"standard\""} +fastapi-cli = {version = ">=0.0.32", extras = ["standard"], optional = true, markers = "extra == \"standard\""} fastar = {version = ">=0.9.0", optional = true, markers = "extra == \"standard\""} httpx = {version = ">=0.23.0,<1.0.0", optional = true, markers = "extra == \"standard\""} jinja2 = {version = ">=3.1.5", optional = true, markers = "extra == \"standard\""} @@ -977,20 +985,20 @@ typing-inspection = ">=0.4.2" uvicorn = {version = ">=0.12.0", extras = ["standard"], optional = true, markers = "extra == \"standard\""} [package.extras] -all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "uvicorn[standard] (>=0.12.0)"] -standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "fastar (>=0.9.0)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] -standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] +all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.32)", "httpx (>=0.23.0,<1.0.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.32)", "fastar (>=0.9.0)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] +standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.32)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] [[package]] name = "fastapi-cli" -version = "0.0.28" +version = "0.0.32" description = "Run and manage FastAPI apps from the command line with FastAPI CLI. 🚀" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "fastapi_cli-0.0.28-py3-none-any.whl", hash = "sha256:396d3b012a15e7c4f12c4c5c7da208bd24ae98882f420c4e2d324d9c7cab8142"}, - {file = "fastapi_cli-0.0.28.tar.gz", hash = "sha256:37b384fa1dbb96ac036e7d0cec9eecf5ed37e632b5eda0a2441c7a4ee2274c1b"}, + {file = "fastapi_cli-0.0.32-py3-none-any.whl", hash = "sha256:8dcc286fa32f01bbd3f65dd09cfd5a2540ed5f2230b77db7fd30978d6165f3c4"}, + {file = "fastapi_cli-0.0.32.tar.gz", hash = "sha256:38024d2345275e1b37ce8848727a580d84901b570e96b3256d9d36a9a5039424"}, ] [package.dependencies] @@ -1376,11 +1384,12 @@ version = "1.75.1" description = "Common protobufs used in Google APIs" optional = false python-versions = ">=3.10" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "googleapis_common_protos-1.75.1-py3-none-any.whl", hash = "sha256:28a1934bcd33b9c9da66ac301a0a4227e3367f095a17d0375cb98f0a09d93b79"}, {file = "googleapis_common_protos-1.75.1.tar.gz", hash = "sha256:d3042c6c5a2d4e67113104d6b6818b59b6bd92a197f2a91508e801fe815cf071"}, ] +markers = {main = "extra == \"tracing\" or extra == \"all\""} [package.dependencies] protobuf = ">=6.33.5,<8.0.0" @@ -1394,8 +1403,7 @@ version = "3.5.3" description = "Lightweight in-process concurrent programming" optional = false python-versions = ">=3.10" -groups = ["main"] -markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\"" +groups = ["main", "dev"] files = [ {file = "greenlet-3.5.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c180d22d325fb613956b443c3c6f4406eb70e6defc70d3974da2a7b59e06f48c"}, {file = "greenlet-3.5.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483d08c11181c83a6ce1a7a61df0f624a208ec40817a3bb2302714592eee4f04"}, @@ -1477,6 +1485,7 @@ files = [ {file = "greenlet-3.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:b7068bd09f761f3f5b4d214c2bed063186b2a86148c740b3873e3f56d79bac31"}, {file = "greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1"}, ] +markers = {main = "extra == \"postgres\" or extra == \"sqlite\" or extra == \"db\" or extra == \"all\""} [package.extras] docs = ["Sphinx", "furo"] @@ -1515,7 +1524,7 @@ version = "3.4.0" description = "Python wrapper for hiredis" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "hiredis-3.4.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:69d0326f20354ce278cbb86f5ae47cb390e22bb94a66877031038af907c42fa5"}, {file = "hiredis-3.4.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:4863b99b1bf739eaa60961798efc709f657864fbf5a142cb9b99d3e36a37208e"}, @@ -1630,6 +1639,7 @@ files = [ {file = "hiredis-3.4.0-cp39-cp39-win_arm64.whl", hash = "sha256:386b556f48fb0f9f09696b9d37f1cae554123fbd9f091d0ca23087f2aca02887"}, {file = "hiredis-3.4.0.tar.gz", hash = "sha256:da19331354433af6a2c54c21f2d70ba084933c0d7d2c43578ec5c5b446674ad5"}, ] +markers = {main = "extra == \"redis\" or extra == \"all\""} [[package]] name = "htmlmin2" @@ -1841,11 +1851,12 @@ version = "5.6.2" description = "Messaging library for Python." optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "kombu-5.6.2-py3-none-any.whl", hash = "sha256:efcfc559da324d41d61ca311b0c64965ea35b4c55cc04ee36e55386145dace93"}, {file = "kombu-5.6.2.tar.gz", hash = "sha256:8060497058066c6f5aed7c26d7cd0d3b574990b09de842a8c5aaed0b92cc5a55"}, ] +markers = {main = "extra == \"worker\" or extra == \"all\""} [package.dependencies] amqp = ">=5.1.1,<6.0.0" @@ -1877,11 +1888,12 @@ version = "1.3.12" description = "A super-fast templating language that borrows the best ideas from the existing templating languages." optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9"}, {file = "mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a"}, ] +markers = {main = "extra == \"migrations\" or extra == \"all\" or extra == \"xcli\""} [package.dependencies] MarkupSafe = ">=0.9.2" @@ -1937,7 +1949,7 @@ version = "3.0.3" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.9" -groups = ["main", "docs"] +groups = ["main", "dev", "docs"] files = [ {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, @@ -2415,107 +2427,152 @@ files = [ [[package]] name = "opentelemetry-api" -version = "1.44.0" +version = "1.45.0" description = "OpenTelemetry Python API" optional = false python-versions = ">=3.10" -groups = ["main"] +groups = ["main", "dev"] files = [ - {file = "opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef"}, - {file = "opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a"}, + {file = "opentelemetry_api-1.45.0-py3-none-any.whl", hash = "sha256:80e068aba7cd56c8b58512d6a36f8d25cb1dfaa0c0a4cc1c938ccf9f362d9cb3"}, + {file = "opentelemetry_api-1.45.0.tar.gz", hash = "sha256:711ede81773c8025c2c03dac0450bc89f3d30aea6eabcc815c570d4e35a963f7"}, ] [package.dependencies] typing-extensions = ">=4.5.0" +[[package]] +name = "opentelemetry-exporter-http-transport" +version = "0.66b0" +description = "OpenTelemetry Exporters HTTP transport" +optional = false +python-versions = ">=3.10" +groups = ["main", "dev"] +files = [ + {file = "opentelemetry_exporter_http_transport-0.66b0-py3-none-any.whl", hash = "sha256:c82689928a11505a2a0a26a04afe0ce06d270d3db984bc16c1a80ffa902db00e"}, + {file = "opentelemetry_exporter_http_transport-0.66b0.tar.gz", hash = "sha256:2c229b6593eaa22c86d9b8a15843dc23b406dbda00fb138339189aab07923b4e"}, +] +markers = {main = "extra == \"tracing\" or extra == \"all\""} + +[package.dependencies] +opentelemetry-api = ">=1.15,<2.0" +urllib3 = {version = ">=1.26", optional = true, markers = "extra == \"urllib3\""} + +[package.extras] +requests = ["requests (>=2.25,<3.0)"] +urllib3 = ["urllib3 (>=1.26)"] + +[[package]] +name = "opentelemetry-exporter-otlp-common" +version = "0.66b0" +description = "OpenTelemetry OTLP HTTP export utilities" +optional = false +python-versions = ">=3.10" +groups = ["main", "dev"] +files = [ + {file = "opentelemetry_exporter_otlp_common-0.66b0-py3-none-any.whl", hash = "sha256:35d24c867310f4713a9738b0202b6bade77955418fccb9e2f7eea01b76263916"}, + {file = "opentelemetry_exporter_otlp_common-0.66b0.tar.gz", hash = "sha256:362268ec6aa705e183776ff938539df1e8ce45bc5509d242538b1d40c26fe6a6"}, +] +markers = {main = "extra == \"tracing\" or extra == \"all\""} + +[package.dependencies] +opentelemetry-sdk = ">=1.45.0,<1.46.0" + +[package.extras] +http = ["opentelemetry-exporter-http-transport (==0.66b0)"] + [[package]] name = "opentelemetry-exporter-otlp-proto-common" -version = "1.44.0" +version = "1.45.0" description = "OpenTelemetry Protobuf encoding" optional = false python-versions = ">=3.10" -groups = ["main"] +groups = ["main", "dev"] files = [ - {file = "opentelemetry_exporter_otlp_proto_common-1.44.0-py3-none-any.whl", hash = "sha256:9a9fe61bba73d802904bc989f1d6b4a7b1ee40f06c40e98d6f85af65aaebb694"}, - {file = "opentelemetry_exporter_otlp_proto_common-1.44.0.tar.gz", hash = "sha256:dc87a5a5bc58f149a56d1547e4691588fa12994cdc3bc039a694ccb3375862ac"}, + {file = "opentelemetry_exporter_otlp_proto_common-1.45.0-py3-none-any.whl", hash = "sha256:7e1410ae6d3ed301f7a74bec96467d519d3f37e52f9d95b309ae125e5a1af863"}, + {file = "opentelemetry_exporter_otlp_proto_common-1.45.0.tar.gz", hash = "sha256:36495115a0c6a7aa946cfda9d59b6ed4e917b6ab0f75cdaf66bc1b176ec1be1f"}, ] +markers = {main = "extra == \"tracing\" or extra == \"all\""} [package.dependencies] -opentelemetry-proto = "1.44.0" +opentelemetry-proto = "1.45.0" [[package]] name = "opentelemetry-exporter-otlp-proto-http" -version = "1.44.0" +version = "1.45.0" description = "OpenTelemetry Collector Protobuf over HTTP Exporter" optional = false python-versions = ">=3.10" -groups = ["main"] +groups = ["main", "dev"] files = [ - {file = "opentelemetry_exporter_otlp_proto_http-1.44.0-py3-none-any.whl", hash = "sha256:838592fce774c1c8bb7b9a0a7facbfa82e17be5a8a4e94cef10cb84ae026bae3"}, - {file = "opentelemetry_exporter_otlp_proto_http-1.44.0.tar.gz", hash = "sha256:c633d7270ad6b57cd4cfbe8b0007a9e2e7c0cb50bd6c50fe2a7b245f721a09d8"}, + {file = "opentelemetry_exporter_otlp_proto_http-1.45.0-py3-none-any.whl", hash = "sha256:74385f99266dafdefff41f3da4b76f6dba79d237a9d4487e6ff1ee653c74a0a3"}, + {file = "opentelemetry_exporter_otlp_proto_http-1.45.0.tar.gz", hash = "sha256:2f35496d96809f946f41b8805e6b93aec6c9b71b5fd759b75b6af4c084d992ae"}, ] +markers = {main = "extra == \"tracing\" or extra == \"all\""} [package.dependencies] googleapis-common-protos = ">=1.52,<2.0" opentelemetry-api = ">=1.15,<2.0" -opentelemetry-exporter-otlp-proto-common = "1.44.0" -opentelemetry-proto = "1.44.0" -opentelemetry-sdk = ">=1.44.0,<1.45.0" -requests = ">=2.7,<3.0" +opentelemetry-exporter-http-transport = {version = "0.66b0", extras = ["urllib3"]} +opentelemetry-exporter-otlp-common = "0.66b0" +opentelemetry-exporter-otlp-proto-common = "1.45.0" +opentelemetry-proto = "1.45.0" +opentelemetry-sdk = ">=1.45.0,<1.46.0" typing-extensions = ">=4.5.0" [package.extras] gcp-auth = ["opentelemetry-exporter-credential-provider-gcp (>=0.59b0)"] +requests = ["opentelemetry-exporter-http-transport[requests] (==0.66b0)", "requests (>=2.7,<3.0)"] [[package]] name = "opentelemetry-proto" -version = "1.44.0" +version = "1.45.0" description = "OpenTelemetry Python Proto" optional = false python-versions = ">=3.10" -groups = ["main"] +groups = ["main", "dev"] files = [ - {file = "opentelemetry_proto-1.44.0-py3-none-any.whl", hash = "sha256:898b155a0e1557afd867478fb6158e8122a46329ca0bb8dc53cc55e98f017f56"}, - {file = "opentelemetry_proto-1.44.0.tar.gz", hash = "sha256:c547a79c2f8c0c515d31509154682e5921c7cfd5ca67b70e1f9266e2c3e103f3"}, + {file = "opentelemetry_proto-1.45.0-py3-none-any.whl", hash = "sha256:9731566359d7b8e1ee1e149d8e4ad6865bab965e657ad3387ec2784d16927666"}, + {file = "opentelemetry_proto-1.45.0.tar.gz", hash = "sha256:96ee414f24bc3f61ea8e17dc56b4348d4049d73db3eb17c6b3edf75b5b403300"}, ] +markers = {main = "extra == \"tracing\" or extra == \"all\""} [package.dependencies] protobuf = ">=5.0,<8.0" [[package]] name = "opentelemetry-sdk" -version = "1.44.0" +version = "1.45.0" description = "OpenTelemetry Python SDK" optional = false python-versions = ">=3.10" -groups = ["main"] +groups = ["main", "dev"] files = [ - {file = "opentelemetry_sdk-1.44.0-py3-none-any.whl", hash = "sha256:df081c4c6bcfdb1211e3e86140376792643128a25f8d72d1d27675936e7e96ad"}, - {file = "opentelemetry_sdk-1.44.0.tar.gz", hash = "sha256:cebe7f65dc12f26ead75c6064de12fd2a9052e5060c0272d402cfa203aae123b"}, + {file = "opentelemetry_sdk-1.45.0-py3-none-any.whl", hash = "sha256:5dc634c946546f61b757c5b1781f9fd1a10e96ffaf7357c797e508fe9c57e75e"}, + {file = "opentelemetry_sdk-1.45.0.tar.gz", hash = "sha256:20caa5130505e386c67c3da1c76e446c842698ced54c76c6148679539aa97972"}, ] [package.dependencies] -opentelemetry-api = "1.44.0" -opentelemetry-semantic-conventions = "0.65b0" +opentelemetry-api = "1.45.0" +opentelemetry-semantic-conventions = "0.66b0" typing-extensions = ">=4.5.0" [package.extras] -file-configuration = ["opentelemetry-configuration (==0.65b0)"] +file-configuration = ["opentelemetry-configuration (==0.66b0)"] [[package]] name = "opentelemetry-semantic-conventions" -version = "0.65b0" +version = "0.66b0" description = "OpenTelemetry Semantic Conventions" optional = false python-versions = ">=3.10" -groups = ["main"] +groups = ["main", "dev"] files = [ - {file = "opentelemetry_semantic_conventions-0.65b0-py3-none-any.whl", hash = "sha256:1cacde7b0ad306f84c5ef08c3dbe1bbaf20165bba6f8bff43b670e555a086bcb"}, - {file = "opentelemetry_semantic_conventions-0.65b0.tar.gz", hash = "sha256:f9b2b81e9d5b64f11bc952075e7e9c7fb0aab075c7fd1c46d597f1b919852d60"}, + {file = "opentelemetry_semantic_conventions-0.66b0-py3-none-any.whl", hash = "sha256:175b19dd98c4473f4f43a2b1df59186fd7b4a48cd3f77cbe03438b6d6fda230a"}, + {file = "opentelemetry_semantic_conventions-0.66b0.tar.gz", hash = "sha256:97a77dce484c54861e7eeff7651fd8a806dd3c30e501dc316730215ec36890e6"}, ] [package.dependencies] -opentelemetry-api = "1.44.0" +opentelemetry-api = "1.45.0" typing-extensions = ">=4.5.0" [[package]] @@ -2529,6 +2586,7 @@ files = [ {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, ] +markers = {main = "extra == \"worker\" or extra == \"all\""} [[package]] name = "paginate" @@ -2612,15 +2670,16 @@ virtualenv = ">=20.10.0" [[package]] name = "prometheus-client" -version = "0.25.0" +version = "0.26.0" description = "Python client for the Prometheus monitoring system." optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ - {file = "prometheus_client-0.25.0-py3-none-any.whl", hash = "sha256:d5aec89e349a6ec230805d0df882f3807f74fd6c1a2fa86864e3c2279059fed1"}, - {file = "prometheus_client-0.25.0.tar.gz", hash = "sha256:5e373b75c31afb3c86f1a52fa1ad470c9aace18082d39ec0d2f918d11cc9ba28"}, + {file = "prometheus_client-0.26.0-py3-none-any.whl", hash = "sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6"}, + {file = "prometheus_client-0.26.0.tar.gz", hash = "sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b"}, ] +markers = {main = "extra == \"metrics\" or extra == \"all\""} [package.extras] aiohttp = ["aiohttp"] @@ -2633,11 +2692,12 @@ version = "3.0.52" description = "Library for building powerful interactive command lines in Python" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955"}, {file = "prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855"}, ] +markers = {main = "extra == \"worker\" or extra == \"all\""} [package.dependencies] wcwidth = "*" @@ -2779,7 +2839,7 @@ version = "7.35.1" description = "" optional = false python-versions = ">=3.10" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6"}, {file = "protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799"}, @@ -2790,6 +2850,7 @@ files = [ {file = "protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9"}, {file = "protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a"}, ] +markers = {main = "extra == \"tracing\" or extra == \"all\""} [[package]] name = "psutil" @@ -2829,20 +2890,21 @@ test = ["psleak", "pytest", "pytest-instafail", "pytest-xdist", "pywin32 ; os_na [[package]] name = "psycopg2" -version = "2.9.12" +version = "2.9.13" description = "psycopg2 - Python-PostgreSQL Database Adapter" optional = false -python-versions = ">=3.9" -groups = ["main"] +python-versions = ">=3.10" +groups = ["main", "dev"] files = [ - {file = "psycopg2-2.9.12-cp310-cp310-win_amd64.whl", hash = "sha256:d5fbe092315fb007c03544704e6d1e678a6c0378139d01cea433dc59edf041b4"}, - {file = "psycopg2-2.9.12-cp311-cp311-win_amd64.whl", hash = "sha256:2532c0cdc6ad18c9c35cd935cc3159712e14f05276a6d29a6435c52d24b840c1"}, - {file = "psycopg2-2.9.12-cp312-cp312-win_amd64.whl", hash = "sha256:83d48e66e18c301d832e93c984a7bcbc0f4ac3bb79e2137e3bc335978c756dc0"}, - {file = "psycopg2-2.9.12-cp313-cp313-win_amd64.whl", hash = "sha256:3d23e684927d37b95cee9a943f6927b04ae2fdcd056fd0e2a30929ee89fee5a9"}, - {file = "psycopg2-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:a73d5513bfe929c56555006c7a9cc7ae6e4276aa99dd2b1e2544eb8bb54f8b23"}, - {file = "psycopg2-2.9.12-cp39-cp39-win_amd64.whl", hash = "sha256:09826a6b89714626a662275d03f21639f1c68d183e2dcc9ba134d463a3da753e"}, - {file = "psycopg2-2.9.12.tar.gz", hash = "sha256:1dedb1c7a1d8552c4a6044c6b1c41a52e6a8e2d144af83eccac758076b1b7c15"}, + {file = "psycopg2-2.9.13-cp310-cp310-win_amd64.whl", hash = "sha256:7d48416f6a4823ada9b33771085331b842b553df88435701bff5ddb4469905de"}, + {file = "psycopg2-2.9.13-cp311-cp311-win_amd64.whl", hash = "sha256:d16e7a5f5e400ac51ca953d42255804eff6c8a9650b1a2074f6ca6261d740382"}, + {file = "psycopg2-2.9.13-cp312-cp312-win_amd64.whl", hash = "sha256:10f7408b34412e8c0d4f8b1565541f1d651b1d00447857e5d8561b38f5c1a738"}, + {file = "psycopg2-2.9.13-cp313-cp313-win_amd64.whl", hash = "sha256:165e25c1b0e616a1f28080c5c68bd2dc015051d83c90240b2171d3e76ca2b5ff"}, + {file = "psycopg2-2.9.13-cp314-cp314-win_amd64.whl", hash = "sha256:a6f54fd8e0024f35240866b5dfff9ead2a0dbd33b8096eec438bc6093412842d"}, + {file = "psycopg2-2.9.13-cp315-cp315-win_amd64.whl", hash = "sha256:0d2fc7eedfaca0586dcf1476454598428d0d8471d3b5cb55f92015a5f9d0af40"}, + {file = "psycopg2-2.9.13.tar.gz", hash = "sha256:d36784fc2dae69523ba4b79c7d1d1b4d6e83e87836874f111262f4db940b16a6"}, ] +markers = {main = "extra == \"postgres\" or extra == \"db\" or extra == \"all\""} [[package]] name = "py-cpuinfo" @@ -2870,20 +2932,20 @@ files = [ [[package]] name = "pydantic" -version = "2.13.4" +version = "2.13.5" description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba"}, - {file = "pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6"}, + {file = "pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73"}, + {file = "pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08"}, ] [package.dependencies] annotated-types = ">=0.6.0" email-validator = {version = ">=2.0.0", optional = true, markers = "extra == \"email\""} -pydantic-core = "2.46.4" +pydantic-core = "2.46.5" typing-extensions = ">=4.14.1" typing-inspection = ">=0.4.2" @@ -2893,132 +2955,132 @@ timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows [[package]] name = "pydantic-core" -version = "2.46.4" +version = "2.46.5" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4"}, - {file = "pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5"}, - {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba"}, - {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b"}, - {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c"}, - {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50"}, - {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd"}, - {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01"}, - {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d"}, - {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4"}, - {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f"}, - {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39"}, - {file = "pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d"}, - {file = "pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf"}, - {file = "pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594"}, - {file = "pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c"}, - {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826"}, - {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04"}, - {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e"}, - {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3"}, - {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4"}, - {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398"}, - {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3"}, - {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848"}, - {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3"}, - {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109"}, - {file = "pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda"}, - {file = "pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33"}, - {file = "pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d"}, - {file = "pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2"}, - {file = "pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f"}, - {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7"}, - {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7"}, - {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712"}, - {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4"}, - {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce"}, - {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987"}, - {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b"}, - {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458"}, - {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b"}, - {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c"}, - {file = "pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894"}, - {file = "pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89"}, - {file = "pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a"}, - {file = "pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008"}, - {file = "pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4"}, - {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76"}, - {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3"}, - {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76"}, - {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4"}, - {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a"}, - {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262"}, - {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e"}, - {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd"}, - {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be"}, - {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d"}, - {file = "pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb"}, - {file = "pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292"}, - {file = "pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d"}, - {file = "pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb"}, - {file = "pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462"}, - {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9"}, - {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4"}, - {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914"}, - {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28"}, - {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b"}, - {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c"}, - {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb"}, - {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898"}, - {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e"}, - {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519"}, - {file = "pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4"}, - {file = "pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac"}, - {file = "pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a"}, - {file = "pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0"}, - {file = "pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d"}, - {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b"}, - {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000"}, - {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e"}, - {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd"}, - {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3"}, - {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7"}, - {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff"}, - {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424"}, - {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6"}, - {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565"}, - {file = "pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02"}, - {file = "pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5"}, - {file = "pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596"}, - {file = "pydantic_core-2.46.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae"}, - {file = "pydantic_core-2.46.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201"}, - {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0"}, - {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15"}, - {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76"}, - {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49"}, - {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928"}, - {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066"}, - {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6"}, - {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9"}, - {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29"}, - {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9"}, - {file = "pydantic_core-2.46.4-cp39-cp39-win32.whl", hash = "sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1"}, - {file = "pydantic_core-2.46.4-cp39-cp39-win_amd64.whl", hash = "sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac"}, - {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c"}, - {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b"}, - {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b"}, - {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea"}, - {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7"}, - {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df"}, - {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526"}, - {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0"}, - {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0"}, - {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7"}, - {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2"}, - {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9"}, - {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf"}, - {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30"}, - {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc"}, - {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983"}, - {file = "pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1"}, + {file = "pydantic_core-2.46.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:657b40d6240c0a7b6a64b30f22d1e3aa631c7e846c621b0c0f6d1d75e2e15ea6"}, + {file = "pydantic_core-2.46.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615"}, + {file = "pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dedce55295becb61921e386b99d4f2706045306e7fa52249a33004c837379fb"}, + {file = "pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9f47b8a949e60f027f0aa0a6f6c7b7e9c55cbf4380d10b344e282fa4e7ab1e1b"}, + {file = "pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:200aa3dc9f8d54f0754f43247c0bad0999fdcfbfd2488384dd44f37279271fe6"}, + {file = "pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6d30e1a4f138b8951063e9a394752a9179b51da288ffa507b1e659222f4c1793"}, + {file = "pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:850a08d167dde16db8702c274f320c7be9d7da6f6dff2b58b18f9e815bd94f5b"}, + {file = "pydantic_core-2.46.5-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:c3471e5c4a949c26ec00a77f01df59096aa9495877de76fd60a980f8ee6be461"}, + {file = "pydantic_core-2.46.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a3e26b6a8274211bddee2d0e4d0d42778f17a34510f49d2ec44b58abfc41736"}, + {file = "pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fc5d783bd4a2387e97b8a2d5ec781cfb92b3d893bf82370548e99db5915935d3"}, + {file = "pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:356c8368cbc321050b169595683a2e1d63413b1e0e2868b330af9fc14c616d3f"}, + {file = "pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:eb7d8d0e5886a89a55d2eef490e272fa965a9d57c6b29a5b5088a7997ec2cad1"}, + {file = "pydantic_core-2.46.5-cp310-cp310-win32.whl", hash = "sha256:4d44cf99ddebf875f9b68cc267aa684c99b7b44fe63ee1cac4ec163807290069"}, + {file = "pydantic_core-2.46.5-cp310-cp310-win_amd64.whl", hash = "sha256:1e5aad1220a1192c42341c8fd4a8686657e73ab2a920c970bdc4de334fe3193d"}, + {file = "pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f"}, + {file = "pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f"}, + {file = "pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061"}, + {file = "pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be"}, + {file = "pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a"}, + {file = "pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b"}, + {file = "pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c"}, + {file = "pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee"}, + {file = "pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e"}, + {file = "pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2"}, + {file = "pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689"}, + {file = "pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec"}, + {file = "pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129"}, + {file = "pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c"}, + {file = "pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8"}, + {file = "pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d"}, + {file = "pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e"}, + {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29"}, + {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4"}, + {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a"}, + {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62"}, + {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2"}, + {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869"}, + {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5"}, + {file = "pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3"}, + {file = "pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b"}, + {file = "pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0"}, + {file = "pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b"}, + {file = "pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8"}, + {file = "pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084"}, + {file = "pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0"}, + {file = "pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff"}, + {file = "pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931"}, + {file = "pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f"}, + {file = "pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038"}, + {file = "pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f"}, + {file = "pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1"}, + {file = "pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761"}, + {file = "pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5"}, + {file = "pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e"}, + {file = "pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed"}, + {file = "pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519"}, + {file = "pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea"}, + {file = "pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5"}, + {file = "pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575"}, + {file = "pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355"}, + {file = "pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e"}, + {file = "pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3"}, + {file = "pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c"}, + {file = "pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21"}, + {file = "pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f"}, + {file = "pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f"}, + {file = "pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a"}, + {file = "pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821"}, + {file = "pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2"}, + {file = "pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47"}, + {file = "pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a"}, + {file = "pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074"}, + {file = "pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0"}, + {file = "pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f"}, + {file = "pydantic_core-2.46.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c583b927a8838dab890706a6fa7573fbb8b70e24000ef9f7238e2d6f6435a5ed"}, + {file = "pydantic_core-2.46.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cdc8b74ecc48c0cb1e9607a05ec4e9e88db60a19ffcc9a1d5f9088ede40c8dc0"}, + {file = "pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b10e3e8fd7ddc2bd915848a2768e44c15b22936f1cc54c462ad1164deb02655"}, + {file = "pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f077d0b97ab11fa7dcc633fca53515f290bca8a8a633e966d5b6d1879d9ed01a"}, + {file = "pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7b0fc826b16c55e561e5d2a0c5c77b051ba1d92808118c4e4b5390f5e0cf191d"}, + {file = "pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef3fbbf161dc9351a2fe0422e51b129f9e97e42385bd0320b309c15f7d287dd8"}, + {file = "pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:978e7b97d4824b5be09c69fb70507cbde3b0323fc147332ca40a94d9a6a0ebbf"}, + {file = "pydantic_core-2.46.5-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:9b68938dd5b0c783d88ff8e2dcc69451b5eb936fe212d516b21b9d5567f6d464"}, + {file = "pydantic_core-2.46.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:771cf63ae0b1b50dd22e5f3e3549fab5f3f4ff1635d352a9e1a97fe01c7b2e64"}, + {file = "pydantic_core-2.46.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7c6be839a5a8312626b32029a415644a0846b420bc8b52b95b28cd92da162168"}, + {file = "pydantic_core-2.46.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:895395f8918627b04efb1ad2a4cf605387143300ba03304cd1dfa6d03f5e095e"}, + {file = "pydantic_core-2.46.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:fc8515076c11f3cfdf4fb142dcca0fe384b1230a3b5415458ac84f3e0903ec13"}, + {file = "pydantic_core-2.46.5-cp39-cp39-win32.whl", hash = "sha256:3d2652072b2d774947ba5cf78a9e59644ac62ee572daf6dd2e1dfe905e15b2b7"}, + {file = "pydantic_core-2.46.5-cp39-cp39-win_amd64.whl", hash = "sha256:3aa166e99c4f2985407fb8714aebede877ecb5455cf321b606adca926d30d5a0"}, + {file = "pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2"}, + {file = "pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c"}, + {file = "pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47"}, + {file = "pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a"}, + {file = "pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942"}, + {file = "pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f"}, + {file = "pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433"}, + {file = "pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c"}, + {file = "pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f"}, + {file = "pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0"}, + {file = "pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4"}, + {file = "pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25"}, + {file = "pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6"}, + {file = "pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e"}, + {file = "pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda"}, + {file = "pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266"}, + {file = "pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc"}, ] [package.dependencies] @@ -3209,11 +3271,12 @@ version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main", "docs"] +groups = ["main", "dev", "docs"] files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, ] +markers = {main = "extra == \"worker\" or extra == \"all\""} [package.dependencies] six = ">=1.5" @@ -3240,14 +3303,14 @@ testing = ["covdefaults (>=2.3)", "coverage (>=7.5.4)", "pytest (>=8.3.5)", "pyt [[package]] name = "python-dotenv" -version = "1.2.2" +version = "1.2.3" description = "Read key-value pairs from a .env file and set them as environment variables" optional = false python-versions = ">=3.10" -groups = ["main"] +groups = ["main", "dev"] files = [ - {file = "python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"}, - {file = "python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"}, + {file = "python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9"}, + {file = "python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35"}, ] [package.extras] @@ -3424,11 +3487,12 @@ version = "7.4.1" description = "Python client for Redis database and key-value store" optional = false python-versions = ">=3.10" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "redis-7.4.1-py3-none-any.whl", hash = "sha256:1fa4647af1c5e93a2c685aa248ee44cce092691146d41390518dabe9a99839b0"}, {file = "redis-7.4.1.tar.gz", hash = "sha256:1a1df5067062cf7cbe677994e391f8ee0840f499d370f1a71266e0dd3aa9308e"}, ] +markers = {main = "extra == \"redis\" or extra == \"all\""} [package.dependencies] hiredis = {version = ">=3.2.0", optional = true, markers = "extra == \"hiredis\""} @@ -3447,7 +3511,7 @@ version = "2.34.2" description = "Python HTTP for Humans." optional = false python-versions = ">=3.10" -groups = ["main", "docs"] +groups = ["docs"] files = [ {file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"}, {file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"}, @@ -3745,106 +3809,125 @@ version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main", "docs"] +groups = ["main", "dev", "docs"] files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, ] +markers = {main = "extra == \"worker\" or extra == \"all\""} [[package]] name = "sqlalchemy" -version = "2.0.51" +version = "2.1.1" description = "Database Abstraction Library" optional = false -python-versions = ">=3.7" -groups = ["main"] +python-versions = ">=3.11" +groups = ["main", "dev"] files = [ - {file = "sqlalchemy-2.0.51-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e8203d2fbd5c6254692ef0a72c740d75b2f3c7ca345404f4c1a4604813c77c0"}, - {file = "sqlalchemy-2.0.51-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1af05726b3d0cdba1c55284bf408fd3b792e690fe2399bfb8304565551cda652"}, - {file = "sqlalchemy-2.0.51-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e54ff2dd657f2e3e0fbf2b097db1182f7bfea263eca4353f00065bae2a67c3d"}, - {file = "sqlalchemy-2.0.51-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1e47b1199c2e832e325eacabc8d32d2487f58c9358f97e9a00f5eb93c5680d84"}, - {file = "sqlalchemy-2.0.51-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c68568f3facf8f66fa76c60e0ced69b67666ffa9941d1d0a3756fda196049080"}, - {file = "sqlalchemy-2.0.51-cp310-cp310-win32.whl", hash = "sha256:0592bdadf86ddcabfd72d9ab66ea8a5d8d2cc6be1cc51fa7e66c03868ac5eac1"}, - {file = "sqlalchemy-2.0.51-cp310-cp310-win_amd64.whl", hash = "sha256:740cf6f35351b1ac3d82369152acf1d51d37e3dcf85d4dc0a22ca01410eabe2a"}, - {file = "sqlalchemy-2.0.51-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1aa10c0daee6705294d181daadaa793221e1a59ed55000a3fab1d42b088ce4ba"}, - {file = "sqlalchemy-2.0.51-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5b2ed6d828f1f09bd812861f4f59ca3bc3803f9df871f4555187f0faf018604"}, - {file = "sqlalchemy-2.0.51-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:436728ce18a80f6951a1e11cc6112c2ede9faf20766f1a26195a7c441ca12dbd"}, - {file = "sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dc261707bf5739aea8a541593f3cc1d463c2701fb05fbcbba0ce031b69a21260"}, - {file = "sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a6d26094615306d116dd5e4a51b0304c99dd2356fc569eed6922a80a6bd3b265"}, - {file = "sqlalchemy-2.0.51-cp311-cp311-win32.whl", hash = "sha256:ca8435d13829b92f4a97362d91975154a4015db3a2634154e1754e9a915e6b86"}, - {file = "sqlalchemy-2.0.51-cp311-cp311-win_amd64.whl", hash = "sha256:4a011ea4510683319ce4ed274b56ee05194b39b6da9d09ca7a39388f0fa84dcc"}, - {file = "sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a"}, - {file = "sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e"}, - {file = "sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9"}, - {file = "sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389"}, - {file = "sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d"}, - {file = "sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5"}, - {file = "sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080"}, - {file = "sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07"}, - {file = "sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195"}, - {file = "sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f"}, - {file = "sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400"}, - {file = "sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d"}, - {file = "sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b"}, - {file = "sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5"}, - {file = "sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491"}, - {file = "sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d"}, - {file = "sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54"}, - {file = "sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e"}, - {file = "sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d"}, - {file = "sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8"}, - {file = "sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499"}, - {file = "sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de"}, - {file = "sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7"}, - {file = "sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72"}, - {file = "sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23"}, - {file = "sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522"}, - {file = "sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7"}, - {file = "sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2"}, - {file = "sqlalchemy-2.0.51-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bb1f5062f98b0b3290e72b707747fdd7e0f22d6956b236ba7ca7f5c9971d2da2"}, - {file = "sqlalchemy-2.0.51-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:247acaa29ccef6250dfd6a3eedf8f94ddf23564180a39fe362e32ae9dbdbde46"}, - {file = "sqlalchemy-2.0.51-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c95ef01f53233a305a874a44a63fbfb1d81cd79b49de0f8529b3548cde437e37"}, - {file = "sqlalchemy-2.0.51-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:fa268106c8987639a17a18514cfe0cd9bf17420ab887e1e1bf486da8836135b1"}, - {file = "sqlalchemy-2.0.51-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:b7f08588854bbb724041d9ae9d980d40040c922382e1d9a2ecb390edc4fd5032"}, - {file = "sqlalchemy-2.0.51-cp38-cp38-win32.whl", hash = "sha256:6b588fd681ddf0c196b8df1ea49a8913514894b2b8f945a9511b4b48871f99c8"}, - {file = "sqlalchemy-2.0.51-cp38-cp38-win_amd64.whl", hash = "sha256:ca216e8af5c05e326efc7e28716ac2381a7cf9791749f5ee1849dccdc99c9b00"}, - {file = "sqlalchemy-2.0.51-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:aa18ae738b5170e253ad0bb6c4b0f07585081e8a6e50893e4d911d47b39a0904"}, - {file = "sqlalchemy-2.0.51-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:59cab3686b1bc039dd9cded2f8d0c08a246e84e76bd4ab5b4f18c7cdae293825"}, - {file = "sqlalchemy-2.0.51-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:111604e637da87031255ddc26c7d7bc22bc6af6f5d459ccff3af1b4660233a85"}, - {file = "sqlalchemy-2.0.51-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ad30ae663711786303fbcd46a47516302d201ee49a877cb3fac61f672895110a"}, - {file = "sqlalchemy-2.0.51-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b21f0e7efc7a5c509e953784e9d1575ebb8b4318960e7e7d7a93bb803626cf64"}, - {file = "sqlalchemy-2.0.51-cp39-cp39-win32.whl", hash = "sha256:a42ad6afcbaaa777241e347aa2e29155993045a0d6b7db74da61053ffe875fe0"}, - {file = "sqlalchemy-2.0.51-cp39-cp39-win_amd64.whl", hash = "sha256:2a97eaad21c84b4ef8010b11eeba9fe6153eb0b3df3ff8b6abc309df1b978ef7"}, - {file = "sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5"}, - {file = "sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9"}, -] + {file = "sqlalchemy-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5516bdaacf5521b7de69a5c76961fde2cdc4edcb39730eccff16246a44cb8715"}, + {file = "sqlalchemy-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f083b4b6a6d0061b0f9ffec0ea9ded82700a6bd11ced32ccb42f855f7806f812"}, + {file = "sqlalchemy-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:758d52653c8902baac25269c5b7c6fca4a198e066f2212fd2ed1b98174fd7bbb"}, + {file = "sqlalchemy-2.1.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6a7ff6a02a102e0575d237172e90adc96466e6ab6511eae7fd43775e6ab19db2"}, + {file = "sqlalchemy-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0b91e020ba33c0cb9842be6eb7de32dfebd9ad1e8f9a660a0fdb567fd0aeaed1"}, + {file = "sqlalchemy-2.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:73743f135b489d3551eeb3cbbb3756d6cc41bb68e033d2b7b2b620b11c6c9d12"}, + {file = "sqlalchemy-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0593ed87886c44e94dfac4b22d20ed88b19a7dacd7440177d7156f9b011e4376"}, + {file = "sqlalchemy-2.1.1-cp311-cp311-win32.whl", hash = "sha256:c19633f7614e1af93192f89debfa5bfbfaed93c80dd8903417d1605d019958dc"}, + {file = "sqlalchemy-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:9dcedb4b1ad160a1297772162f9a15fc317627b76ac7c98d12c41ce38522d8ef"}, + {file = "sqlalchemy-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:e08c92feb447b60e352c3581cc8a03119e36917b79362af62fd1a289c4d8e27b"}, + {file = "sqlalchemy-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1abf1acecd4d0fc34d4ab7e9cda9e849ce95b647e1a5f76db7d2a5a0bea4e1e5"}, + {file = "sqlalchemy-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61333b2f806f64a08a1edf0b0e4271905f73c1f7e525c23da7a491f74c109835"}, + {file = "sqlalchemy-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0fad3dc37851b096ef72538b18152a5c1ae9c8b2013cd89f51eca83fc6eba36b"}, + {file = "sqlalchemy-2.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8a3126c1203cdfc0f00884f9e8dbe95307b813f883ee9d9691983a563ebc96f8"}, + {file = "sqlalchemy-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f90e517b2eb9c4c40fcc978c8c242789fa6cb0064c501fd8c1515145ed5fe23d"}, + {file = "sqlalchemy-2.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:054a2c2b6cbe3ca7cb88aba9c5e2ecdbfc43656bea22326a41d09e5398c86ec4"}, + {file = "sqlalchemy-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ea049f3e2702967414bc79f58a484dab191359267726607e6c1579a09ef93788"}, + {file = "sqlalchemy-2.1.1-cp312-cp312-win32.whl", hash = "sha256:842a5c00ca9719cc26fba6f401f70e4bac19dc8cc4dc36350803a03954d6aea2"}, + {file = "sqlalchemy-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:0220e0d1d1ad2391a03cd6713ca5d795419033cc40baa1233f9ad454dffa25e6"}, + {file = "sqlalchemy-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af189bd861bd31a84f0ba244d6db0c8aeae9e56448c9001bf02ee6a93758b39e"}, + {file = "sqlalchemy-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b04365ffe57cdeaf4d0f75630332d857dde3f91336bc4bdec76c810473c9c35e"}, + {file = "sqlalchemy-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dee7886f4725c304641929a38d1ebe163f9fb45a5c9363f34bbb85bfdcb084c5"}, + {file = "sqlalchemy-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abd893deac6b0765f51d87129c5631cc5bc8f784f8bf22a9d3903695c764bccc"}, + {file = "sqlalchemy-2.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c60fb6793cf60b2733012352f0e1861c38b24727d0e37a1da1f3974eb79f0ed"}, + {file = "sqlalchemy-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a74c46cd93e34b912adafab98aa71c862518451a019d97f9c2f37392fc8eea3f"}, + {file = "sqlalchemy-2.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:eef01acb10295adfac8a960f716687052fe16f14a3f5955b56c24eefa9df7851"}, + {file = "sqlalchemy-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ed18c0747eec4a0d8d9abe4c89d96ec0eda97721ab7d3a31d0c1ebcbf38cd2e"}, + {file = "sqlalchemy-2.1.1-cp313-cp313-win32.whl", hash = "sha256:389e7098e9ae15094f222496de98b0fd211c06b896c73c6e46aa9ca5b6fcd590"}, + {file = "sqlalchemy-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:cf88881d593f527860fa7c63a50095496a5fa15632b1fee58e3114353f7fbc22"}, + {file = "sqlalchemy-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:28b882509319789d52966d70164ccfc5bc1aaffc1c7f98a79bbccec13346ebd7"}, + {file = "sqlalchemy-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:597e1edf9cbe6ce63974813000c02c7a44da3d21f1da47f07174732ba188e8ae"}, + {file = "sqlalchemy-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e37b1fe2e080bf47f3b693dfbec9aa39d3c3d52bfac3b4fef290a449570bcf3f"}, + {file = "sqlalchemy-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18148691d761ef20a92cfc7dced305834e2c6ab4f4f3648f66f5d4028e234089"}, + {file = "sqlalchemy-2.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44dff425e7d88b04497b4317478a6af2a694327a087e6f1670a4266180838269"}, + {file = "sqlalchemy-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6a261e2244fa88e6eded95a078550c0584da55db22d49b0b2db8c075788282fb"}, + {file = "sqlalchemy-2.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:4789593e8979d7c946da1985a560200b6ef6c3dc6dcc5442985d62c6f2908875"}, + {file = "sqlalchemy-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1477f2f1f531697753a729dd8f3e4e98f7019086f001fdc4d50350c929871c8c"}, + {file = "sqlalchemy-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1ef7bc38d140b3cf08cc6e8823b8e76da2e188b5594997e2c16586c9778af472"}, + {file = "sqlalchemy-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:73b0f7cb41a3df79dc0427377a9e2a099eb15995a327120d651f21309faa662a"}, + {file = "sqlalchemy-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:a0421f9eedad616b04a097ae3f155c5a0a31380364369861fd6e0f46074c3fce"}, + {file = "sqlalchemy-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2f0bb0502de28baed425278d4bd98658d8256419fd9ad6ca964138710f933aa0"}, + {file = "sqlalchemy-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e20233c7d000c006a03f3734d8e7a6e63aa878fcd3482c1f412c864a0992e5a0"}, + {file = "sqlalchemy-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b0660ded5f9c090f9bf49fba78d52843ff3ca8c10f71624811f42f11655762d"}, + {file = "sqlalchemy-2.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:63c0291bf0f721543db3719c71f2d5ad6649f12abbcbc8324d42805dac112cbc"}, + {file = "sqlalchemy-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:092a743f28f3a660321e9b093144b6a5b16f08dbb9b2f52466b47a2a128475c2"}, + {file = "sqlalchemy-2.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fdef70d59afce463bedef54580ba6a832f9f2313c1d0054e15cbb4ff46f37a97"}, + {file = "sqlalchemy-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0f94929be35def6580cf161f20c71930d9364d62f7040b6aed02b13fd3ca09e6"}, + {file = "sqlalchemy-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:dabf6a73f2cc1fa2155b29f2a8a85afd82d8613262e6054cc3c932f5dee5d474"}, + {file = "sqlalchemy-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:3353333f8c25fc5e0661f54630238a17b0d6600305e224400cd4642cce4af34f"}, + {file = "sqlalchemy-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7f15820c84b2377bcf02cf754232690ac2ad90d425978bf1e5ba30cc04f94d55"}, + {file = "sqlalchemy-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:481d926922d9bff632955ed6f07e2b87066b0d1069eb67cbc8c35e27f7fe5e1e"}, + {file = "sqlalchemy-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2a036e3c8b183f9c31df7611e03a4bd160e055915772bb8c13ec382da8f3571"}, + {file = "sqlalchemy-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb1f36ca818146b0e844dbde6dced65805d4ee06ce6282aa46a807f9a6619ede"}, + {file = "sqlalchemy-2.1.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:583038541d9246dbb05fc4ab73038dc61820983c12fa5e32a3b0d903e1ba7bba"}, + {file = "sqlalchemy-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e5274d07ae1dc5dc0e452ec24bdc97cf76ba189cab3f0c1c0386a1523240f775"}, + {file = "sqlalchemy-2.1.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:f2c1c27a41d1a26db380b3bad45f822d979c9608108c32ee613a418ec454ecbf"}, + {file = "sqlalchemy-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3ac9781474e4b6c3e184487643d47408666be794d5782165a817da63f981f89a"}, + {file = "sqlalchemy-2.1.1-cp315-cp315-win32.whl", hash = "sha256:eb2095d39baecbf8b78ea202d3d21178cc8d73e52c3b450c82feb200d5b0d1a9"}, + {file = "sqlalchemy-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:ee446c07db92e94af30e1390ef7d65c1172756403f743fb7d8b4fc5f377cd427"}, + {file = "sqlalchemy-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:57ae70935cd382cd41d84757e802687f7c5e7411aecf2b962b7bdb6ea74e088c"}, + {file = "sqlalchemy-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:558d6d7d6eec0e54ed212320a587bb7f30553550396226d8488803c93757f17b"}, + {file = "sqlalchemy-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1986faa95fd7b206e5bdd1af7f4ba9ac10f1166225fb2eca0fbca04e3851f7d"}, + {file = "sqlalchemy-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87d45175e8ba4a1ee027ad9307e6a4bf8a242469415f12afc9477def739474b0"}, + {file = "sqlalchemy-2.1.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8d846615b89702ef5d0809e701ff5f3816b88dd02670c777adc456f6d81ef3ef"}, + {file = "sqlalchemy-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9324681cf8d8e9c7f6e507fea6a8ac2215fee253340d4399645216a08b936660"}, + {file = "sqlalchemy-2.1.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:88cb0a27ee9571fe339d7809a40b14ff2387ea05d02629a390a03c87cc981277"}, + {file = "sqlalchemy-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:359e2411cf4713b4b323b3c2558a07663a19ab82a5d7b23c601e1be5432aac0c"}, + {file = "sqlalchemy-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:d111318e32b4452d85b491d2ba3f39f6be2736ff858da616f0e6627bb3810145"}, + {file = "sqlalchemy-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:b357598df0676accd1ccfb6bbc5db53dbfb499052d2d1677adf4ce14a7180664"}, + {file = "sqlalchemy-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:b76d8fddf080c53f2afdea6e7149721e7b980086a2f17a9b0bdb42571a92c285"}, + {file = "sqlalchemy-2.1.1-py3-none-any.whl", hash = "sha256:4357c1a222e141662251a59d3702f9b124294941171dfa6bd570513e9f4905ee"}, + {file = "sqlalchemy-2.1.1.tar.gz", hash = "sha256:fcf3cbb33bb23bad75d2150157c21804618745731e9931ab27e61879a9f6123b"}, +] +markers = {main = "extra == \"postgres\" or extra == \"sqlite\" or extra == \"db\" or extra == \"all\" or extra == \"migrations\" or extra == \"xcli\""} [package.dependencies] -greenlet = {version = ">=1", markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\""} +greenlet = {version = ">=1", optional = true, markers = "extra == \"asyncio\""} typing-extensions = ">=4.6.0" [package.extras] -aiomysql = ["aiomysql (>=0.2.0)", "greenlet (>=1)"] -aioodbc = ["aioodbc", "greenlet (>=1)"] -aiosqlite = ["aiosqlite", "greenlet (>=1)", "typing_extensions (!=3.10.0.1)"] +aiomysql = ["aiomysql", "sqlalchemy[asyncio]"] +aioodbc = ["aioodbc", "sqlalchemy[asyncio]"] +aiosqlite = ["aiosqlite", "sqlalchemy[asyncio]"] asyncio = ["greenlet (>=1)"] -asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (>=1)"] +asyncmy = ["asyncmy (>=0.2.12)", "sqlalchemy[asyncio]"] +cymysql = ["cymysql"] mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10)"] mssql = ["pyodbc"] mssql-pymssql = ["pymssql"] mssql-pyodbc = ["pyodbc"] -mypy = ["mypy (>=0.910)"] +mssql-python = ["mssql-python (>=1.9.0)"] +mypy = ["mypy (>=2.3)", "types-greenlet (>=2)"] mysql = ["mysqlclient (>=1.4.0)"] mysql-connector = ["mysql-connector-python"] -oracle = ["cx_oracle (>=8)"] -oracle-oracledb = ["oracledb (>=1.0.1)"] -postgresql = ["psycopg2 (>=2.7)"] -postgresql-asyncpg = ["asyncpg", "greenlet (>=1)"] -postgresql-pg8000 = ["pg8000 (>=1.29.1)"] -postgresql-psycopg = ["psycopg (>=3.0.7)"] +oracle = ["oracledb (>=2.0.1)"] +oracle-cxoracle = ["cx_oracle (>=8)"] +oracle-oracledb = ["oracledb (>=2.0.1)"] +postgresql = ["psycopg (>=3.0.7,!=3.1.15)"] +postgresql-asyncpg = ["asyncpg", "sqlalchemy[asyncio]"] +postgresql-pg8000 = ["pg8000 (>=1.29.3)"] +postgresql-psycopg = ["psycopg (>=3.0.7,!=3.1.15)"] postgresql-psycopg2binary = ["psycopg2-binary"] postgresql-psycopg2cffi = ["psycopg2cffi"] -postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] +postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7,!=3.1.15)"] pymysql = ["pymysql"] sqlcipher = ["sqlcipher3_binary"] @@ -3908,7 +3991,6 @@ files = [ {file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"}, {file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"}, ] -markers = {dev = "python_version == \"3.12\""} [[package]] name = "typing-inspection" @@ -3936,6 +4018,7 @@ files = [ {file = "tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7"}, {file = "tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10"}, ] +markers = {main = "platform_system == \"Windows\" or extra == \"worker\" or extra == \"all\""} [[package]] name = "tzlocal" @@ -3943,7 +4026,7 @@ version = "5.4.4" description = "tzinfo object for the local timezone" optional = false python-versions = ">=3.10" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "tzlocal-5.4.4-py3-none-any.whl", hash = "sha256:aae09f0126a8a86fa736be266eb4a471380d26a0de3bc14844e7821fee3e2a15"}, {file = "tzlocal-5.4.4.tar.gz", hash = "sha256:8dbb8660838688a7b6ba4fed31d18dedf842afb4d47ca050d6d891c2c15f3be4"}, @@ -3962,7 +4045,7 @@ version = "2.7.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.10" -groups = ["main", "docs"] +groups = ["main", "dev", "docs"] files = [ {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, @@ -4071,11 +4154,12 @@ version = "5.1.0" description = "Python promises." optional = false python-versions = ">=3.6" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc"}, {file = "vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0"}, ] +markers = {main = "extra == \"worker\" or extra == \"all\""} [[package]] name = "virtualenv" @@ -4264,11 +4348,12 @@ version = "0.8.2" description = "Measures the displayed width of unicode strings in a terminal" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85"}, {file = "wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda"}, ] +markers = {main = "extra == \"worker\" or extra == \"all\""} [[package]] name = "websockets" @@ -4517,12 +4602,21 @@ multidict = ">=4.0" propcache = ">=0.2.1" [extras] -all = ["xcdk", "xcorecli", "xcorescanner"] +all = ["aiosqlite", "alembic", "celery", "opentelemetry-exporter-otlp-proto-http", "prometheus-client", "psycopg2", "python-dotenv", "redis", "sqlalchemy", "xcdk", "xcorecli", "xcorescanner"] cpp = ["xcorescanner"] +db = ["aiosqlite", "psycopg2", "sqlalchemy"] +dotenv = ["python-dotenv"] +metrics = ["prometheus-client"] +migrations = ["alembic"] +postgres = ["psycopg2", "sqlalchemy"] +redis = ["redis"] sdk = ["xcdk"] +sqlite = ["aiosqlite", "sqlalchemy"] +tracing = ["opentelemetry-exporter-otlp-proto-http"] +worker = ["celery"] xcli = ["xcorecli"] [metadata] lock-version = "2.1" python-versions = ">=3.12,<4.0" -content-hash = "b6f25778d5af0083617a5b268cc677107c5a99b374f613fcb568aff41e247bb0" +content-hash = "4be4c7dba93366ef2f6637a7cbad2204f95760a03acd7ec3f1e8f0011a601aed" diff --git a/pyproject.toml b/pyproject.toml index 60e0338..5c3443a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "XCoreRuntime" -version = "2.6.0" +version = "2.7.0" description = "Plugin-first orchestration framework built on FastAPI" authors = [ { name = "Eliezer Traore", email = "68350805+traoreera@users.noreply.github.com" }, @@ -16,33 +16,74 @@ classifiers = [ ] requires-python = ">=3.12,<4.0" + +# ── Noyau ─────────────────────────────────────────────────────────────────────── +# Ce que `import xcore` et la config par défaut (zero-config) chargent +# réellement, sans condition : +# - fastapi[standard] : le framework lui-même. +# - pydantic : modèles (router IPC, manifest…) — fastapi[standard] le tire +# déjà en transitif, mais il est importé directement par xcore. +# - pyyaml : chargement d'integration.yaml. +# - apscheduler : SchedulerConfig.enabled=True par défaut (backend memory) — +# le scheduler tourne dès le boot sans configuration explicite. +# - opentelemetry-api/sdk : importés au niveau module de +# kernel/observability/tracing.py, chargé inconditionnellement au boot +# (le tracer est noop par défaut, mais le SDK doit être présent). +# Tout le reste n'est utilisé que si le service correspondant est configuré +# (services.databases, cache/scheduler backend=redis, xworker.enabled…) — +# voir [project.optional-dependencies] ci-dessous. dependencies = [ - "fastapi[standard]>=0.135.1,<1.0.0", - "pydantic>=2.11.7,<3.0.0", - "sqlalchemy>=2.0.41,<3.0.0", - "alembic>=1.16.1,<2.0.0", - "psycopg2>=2.9.11,<3.0.0", - "aiosqlite>=0.22.1,<1.0.0", - "redis[hiredis]>=7.0.0,<8.0.0", - "apscheduler>=3.11.0,<4.0.0", - "python-dotenv>=1.1.0,<2.0.0", + "fastapi[standard]>=0.141.1,<1.0.0", + "pydantic>=2.13.5,<3.0.0", "pyyaml>=6.0.3,<7.0.0", - "celery>=5.6.3,<6.0.0", - "opentelemetry-api>=1.27.0,<2.0.0", - "opentelemetry-sdk>=1.27.0,<2.0.0", - "opentelemetry-exporter-otlp-proto-http>=1.27.0,<2.0.0", + "apscheduler>=3.11.3,<4.0.0", + "opentelemetry-api>=1.45.0,<2.0.0", + "opentelemetry-sdk>=1.45.0,<2.0.0", ] # ── Dépendances optionnelles ───────────────────────────────────────────────────── -# pip install XCoreRuntime[sdk] → SDK complet pour auteurs de plugins (xcdk) -# pip install XCoreRuntime[xcli] → CLI xcore (xcorecli) -# pip install XCoreRuntime[cpp] → accélération C++ du scanner AST (xcorescanner) -# pip install XCoreRuntime[all] → les trois +# Runtime : pip install XCoreRuntime[redis,worker,...] +# postgres driver PostgreSQL sync + async (psycopg2, sqlalchemy[asyncio]) +# — nécessaire pour une URL 'postgresql://...' dans services.databases +# sqlite driver SQLite async (aiosqlite, sqlalchemy[asyncio]) +# — nécessaire pour une URL 'sqlite+aiosqlite://...' +# db postgres + sqlite réunis (confort dev/prod mixte) +# migrations runner de migrations SQLAlchemy (alembic) — MigrationRunner +# importe alembic à la demande, jamais utilisé sinon +# redis backend cache/scheduler distribué (services.cache/scheduler +# backend: redis|tiered) — non utilisé par le backend "memory" par défaut +# worker service xworker (Celery) — instancié seulement si +# services.xworker.enabled=true (False par défaut) +# tracing exporteur OTLP/HTTP (observability.tracing.endpoint) — sans +# endpoint configuré, l'export console (déjà dans le noyau) suffit +# dotenv chargement de fichier .env (app.dotenv / plugin envconfiguration) +# — déjà gracieusement optionnel dans le code (try/except) +# metrics backend de métriques Prometheus (observability.metrics.backend: +# prometheus) — fallback silencieux sur le backend memory sinon +# sdk SDK complet pour auteurs de plugins (xcdk) +# xcli CLI xcore (xcorecli) +# cpp accélération C++ du scanner AST (xcorescanner) +# all tout ce qui précède [project.optional-dependencies] -sdk = ["xcdk>=0.1.0"] -xcli = ["xcorecli>=1.1.0"] -cpp = ["xcorescanner>=0.1.0"] -all = ["xcdk>=0.1.0", "xcorecli>=1.1.0", "xcorescanner>=0.1.0"] +postgres = ["sqlalchemy[asyncio]>=2.1.1,<3.0.0", "psycopg2>=2.9.13,<3.0.0"] +sqlite = ["sqlalchemy[asyncio]>=2.1.1,<3.0.0", "aiosqlite>=0.22.1,<1.0.0"] +db = ["sqlalchemy[asyncio]>=2.1.1,<3.0.0", "psycopg2>=2.9.13,<3.0.0", "aiosqlite>=0.22.1,<1.0.0"] +migrations = ["alembic>=1.20.0,<2.0.0"] +redis = ["redis[hiredis]>=7.0.0,<9.0.0"] +worker = ["celery>=5.6.3,<6.0.0"] +tracing = ["opentelemetry-exporter-otlp-proto-http>=1.45.0,<2.0.0"] +dotenv = ["python-dotenv>=1.2.3,<2.0.0"] +metrics = ["prometheus-client>=0.26.0,<0.27.0"] +sdk = ["xcdk>=0.1.0"] +xcli = ["xcorecli>=1.1.0"] +cpp = ["xcorescanner>=0.1.0"] +all = [ + "sqlalchemy[asyncio]>=2.1.1,<3.0.0", "psycopg2>=2.9.13,<3.0.0", "aiosqlite>=0.22.1,<1.0.0", + "alembic>=1.20.0,<2.0.0", "redis[hiredis]>=7.0.0,<9.0.0", "celery>=5.6.3,<6.0.0", + "opentelemetry-exporter-otlp-proto-http>=1.45.0,<2.0.0", "python-dotenv>=1.2.3,<2.0.0", + "prometheus-client>=0.26.0,<0.27.0", + "xcdk>=0.1.0", "xcorecli>=1.1.0", "xcorescanner>=0.1.0", +] # ── Build system ─────────────────────────────────────────────────────────────── [build-system] @@ -72,7 +113,19 @@ autoflake = "^2.3.1" autopep8 = "^2.3.2" bandit = "^1.8.6" pre-commit = "^4.6.0" -prometheus-client = { version = ">=0.25.0,<0.26.0" } +prometheus-client = { version = ">=0.26.0,<0.27.0" } +# Doublons volontaires des extras runtime optionnels (voir +# [project.optional-dependencies]) : la CI fait `poetry install --with dev` +# sans --extras, donc les tests qui exercent ces backends (DB, redis, celery, +# alembic, exporteur OTLP, dotenv) ont besoin de ces paquets ici aussi. +sqlalchemy = { version = ">=2.1.1,<3.0.0", extras = ["asyncio"] } +psycopg2 = "^2.9.13" +aiosqlite = "^0.22.1" +alembic = "^1.20.0" +redis = { version = ">=7.0.0,<9.0.0", extras = ["hiredis"] } +celery = "^5.6.3" +opentelemetry-exporter-otlp-proto-http = "^1.45.0" +python-dotenv = "^1.2.3" # ── Docs dependencies ────────────────────────────────────────────────────────── [tool.poetry.group.docs.dependencies] mkdocs = "^1.6.1" diff --git a/xcore/__version__.py b/xcore/__version__.py index 0be1fae..50414e6 100644 --- a/xcore/__version__.py +++ b/xcore/__version__.py @@ -1,4 +1,4 @@ -__version__ = "2.6.0" -__version_info__ = (2, 6, 0) +__version__ = "2.7.0" +__version_info__ = (2, 7, 0) __author__ = "xcore contributors" __license__ = "MIT" From 57eec28b1d7898780be734311478ffd26c4dbee4 Mon Sep 17 00:00:00 2001 From: traoreera Date: Mon, 28 Sep 2026 16:02:33 +0000 Subject: [PATCH 02/15] =?UTF-8?q?fix(security):=203=20=C3=A9chappements=20?= =?UTF-8?q?sandbox=20confirm=C3=A9s=20=E2=80=94=20ex=C3=A9cution=20de=20co?= =?UTF-8?q?de=20arbitraire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Analyse dynamique (pas de relecture statique seule) : deux plugins sandboxed réels écrits et exécutés contre un vrai process xcore, tentant les techniques d'évasion sandbox Python connues. Voir reports/sandbox_dynamic_security_analysis_2026-09-28.md pour le détail complet, les PoC, et la vérification post-correctif. 3 vulnérabilités critiques confirmées par exécution réelle, puis corrigées : 1. asyncio.create_subprocess_exec/_shell — asyncio n'était sur aucune des deux listes de modules interdits (scanner statique + guard runtime) 2. ().__class__.__bases__[0].__subclasses__() → subprocess.Popen déjà chargé en mémoire — contourne à la fois le scan AST statique (aucun mot interdit littéral dans le code) et le guard d'import runtime (aucun import jamais exécuté) 3. import posix dynamique — listé dans DEFAULT_FORBIDDEN (scanner statique) mais absent de _FORBIDDEN_MODULES (guard runtime) ; posix expose des primitives quasi équivalentes à os (fork, execve...) Correctif dans xcore/kernel/sandbox/worker.py : - pwd/grp/posix ajoutés à _FORBIDDEN_MODULES (ferme #3 au niveau import) - Nouvelle couche 5 (_install_subprocess_guard) : patch direct des objets déjà en mémoire plutôt que du seul mécanisme d'import — subprocess.Popen.__init__, subprocess.call/run/check_call/check_output, toute la famille os.fork/exec*/spawn*/posix_spawn*/system/popen, et asyncio.create_subprocess_exec/_shell. Ferme aussi #2, qu'un simple ajout à une liste de modules n'aurait pas pu fermer (aucun import n'y est jamais exécuté). Vérifié : les 3 PoC re-testés après correctif sont bloqués ; usage légitime d'asyncio sans subprocess (asyncio.sleep) non affecté ; FilesystemGuard et limites mémoire (RLIMIT_DATA/RSS) vérifiés efficaces par les mêmes tests ; 1310 tests passants, lint clean. Version : 2.6.0 → 2.6.1 (patch de sécurité). --- CHANGELOG.md | 10 ++ doc/changelog.md | 10 ++ pyproject.toml | 2 +- ...ox_dynamic_security_analysis_2026-09-28.md | 144 ++++++++++++++++++ roadmap/ROADMAP_PROGRESS.md | 7 +- roadmap/executed_roadmap.md | 7 +- xcore/__version__.py | 4 +- xcore/kernel/sandbox/worker.py | 81 ++++++++++ 8 files changed, 260 insertions(+), 5 deletions(-) create mode 100644 reports/sandbox_dynamic_security_analysis_2026-09-28.md diff --git a/CHANGELOG.md b/CHANGELOG.md index cfffe57..0f9be87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.6.1] - 2026-09-28 + +### Security +- **3 confirmed sandbox-escape techniques let a `sandboxed` plugin run arbitrary commands on the host**, found via dynamic testing (real plugins executed against a real `Xcore` instance, not static code review — see `reports/sandbox_dynamic_security_analysis_2026-09-28.md`): + - `asyncio.create_subprocess_exec`/`_shell` — `asyncio` was on neither of the two forbidden-module lists. + - `().__class__.__bases__[0].__subclasses__()` walking to an already-loaded `subprocess.Popen` — defeats both the static AST scan (no literal `__subclasses__`/`import subprocess` in source) and the runtime import guard (no `import` statement is ever executed; the class is already resident in memory before the guard installs). + - Dynamic `import posix` — listed in the static scanner's `DEFAULT_FORBIDDEN` but missing from the runtime guard's `_FORBIDDEN_MODULES`; `posix` is the module `os` is built on and exposes near-equivalent primitives (`fork`, `execve`, ...). + - Fixed in `xcore/kernel/sandbox/worker.py`: `pwd`/`grp`/`posix` added to `_FORBIDDEN_MODULES`, and a new guard layer patches the dangerous objects directly wherever they're reached from — `subprocess.Popen.__init__`, `subprocess.call`/`run`/`check_call`/`check_output`, the full `os.fork`/`os.exec*`/`os.spawn*`/`os.posix_spawn*`/`os.system`/`os.popen` family, and `asyncio.create_subprocess_exec`/`_shell` — rather than only gating imports by name. This closes the `__subclasses__()` bypass too, which a name-based fix alone cannot. Legitimate non-subprocess `asyncio` usage (`asyncio.sleep`, etc.) is unaffected — verified. +- Memory limits (`RLIMIT_DATA`/`RLIMIT_RSS`) and the filesystem guard (`allowed_paths`/`denied_paths`, directory traversal) were verified effective by the same dynamic testing — no change needed there. + ## [2.6.0] - 2026-09-28 ### Added diff --git a/doc/changelog.md b/doc/changelog.md index cfffe57..0f9be87 100644 --- a/doc/changelog.md +++ b/doc/changelog.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.6.1] - 2026-09-28 + +### Security +- **3 confirmed sandbox-escape techniques let a `sandboxed` plugin run arbitrary commands on the host**, found via dynamic testing (real plugins executed against a real `Xcore` instance, not static code review — see `reports/sandbox_dynamic_security_analysis_2026-09-28.md`): + - `asyncio.create_subprocess_exec`/`_shell` — `asyncio` was on neither of the two forbidden-module lists. + - `().__class__.__bases__[0].__subclasses__()` walking to an already-loaded `subprocess.Popen` — defeats both the static AST scan (no literal `__subclasses__`/`import subprocess` in source) and the runtime import guard (no `import` statement is ever executed; the class is already resident in memory before the guard installs). + - Dynamic `import posix` — listed in the static scanner's `DEFAULT_FORBIDDEN` but missing from the runtime guard's `_FORBIDDEN_MODULES`; `posix` is the module `os` is built on and exposes near-equivalent primitives (`fork`, `execve`, ...). + - Fixed in `xcore/kernel/sandbox/worker.py`: `pwd`/`grp`/`posix` added to `_FORBIDDEN_MODULES`, and a new guard layer patches the dangerous objects directly wherever they're reached from — `subprocess.Popen.__init__`, `subprocess.call`/`run`/`check_call`/`check_output`, the full `os.fork`/`os.exec*`/`os.spawn*`/`os.posix_spawn*`/`os.system`/`os.popen` family, and `asyncio.create_subprocess_exec`/`_shell` — rather than only gating imports by name. This closes the `__subclasses__()` bypass too, which a name-based fix alone cannot. Legitimate non-subprocess `asyncio` usage (`asyncio.sleep`, etc.) is unaffected — verified. +- Memory limits (`RLIMIT_DATA`/`RLIMIT_RSS`) and the filesystem guard (`allowed_paths`/`denied_paths`, directory traversal) were verified effective by the same dynamic testing — no change needed there. + ## [2.6.0] - 2026-09-28 ### Added diff --git a/pyproject.toml b/pyproject.toml index 60e0338..38da29c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "XCoreRuntime" -version = "2.6.0" +version = "2.6.1" description = "Plugin-first orchestration framework built on FastAPI" authors = [ { name = "Eliezer Traore", email = "68350805+traoreera@users.noreply.github.com" }, diff --git a/reports/sandbox_dynamic_security_analysis_2026-09-28.md b/reports/sandbox_dynamic_security_analysis_2026-09-28.md new file mode 100644 index 0000000..4a906a2 --- /dev/null +++ b/reports/sandbox_dynamic_security_analysis_2026-09-28.md @@ -0,0 +1,144 @@ +# 🛡️ Analyse dynamique de sécurité — Sandbox XCore + +**Date :** 28 septembre 2026 +**Auteur :** Claude (assistant, session courante) +**Version testée :** XCore v2.7.0 (`chore/dependency-extras`) +**Méthode :** analyse **dynamique** — pas de relecture de code seule. Deux plugins `sandboxed` réels ont été écrits et exécutés dans un vrai process xcore (`Xcore().boot()`, sans mock), en tentant les techniques d'évasion sandbox Python connues. Environnement de test isolé (`/tmp/.../scratchpad/sandbox_dynamic_test/`), jamais commité au dépôt. + +--- + +## 📋 1. Synthèse + +Le sandbox XCore repose sur 4 couches documentées (`doc/security/security.md`) : validation du manifeste, scan AST statique, `FilesystemGuard` runtime, signature HMAC (plugins trusted uniquement). Cette analyse dynamique confirme que 3 de ces couches fonctionnent bien en pratique (filesystem, limite mémoire, blocage des imports obfusqués de modules **listés**) — mais révèle **3 techniques d'évasion sandbox fonctionnelles**, toutes confirmées par exécution réelle, qui permettent à un plugin `sandboxed` malveillant d'exécuter des commandes arbitraires sur la machine hôte en contournant totalement l'isolation. + +**Racine commune** : le sandbox applicatif Python bloque des **modules par nom**, vérifiés au moment de l'import. Il a trois angles morts structurels : +- des modules qui donnent un accès système équivalent à `os` sans jamais y figurer (`asyncio.subprocess`, `posix`) ; +- des classes système **déjà chargées en mémoire** au démarrage de l'interpréteur (avant même que le guard ne s'installe), accessibles via l'arbre d'héritage Python (`__subclasses__()`) sans jamais passer par un `import` ; +- une désynchronisation entre la liste du scanner statique (54 entrées) et celle du guard runtime (36 entrées). + +> **Mise à jour du 28 septembre 2026, même session** : les 3 vulnérabilités du §2 sont **corrigées** dans `xcore/kernel/sandbox/worker.py` (`pwd`/`grp`/`posix` ajoutés à `_FORBIDDEN_MODULES`, blocage direct de `subprocess.Popen.__init__`/`os.fork`/`os.execve`/famille `spawn*`/`asyncio.create_subprocess_exec`/`_shell` — piste de remédiation #3 du §6, la plus robuste). Les 3 PoC ont été ré-exécutés après correction : les trois sont désormais bloqués (`PermissionError: [sandbox] ... interdit dans le sandbox`), voir §8. Un usage légitime d'`asyncio` sans subprocess (`asyncio.sleep`) a été vérifié comme non affecté. Suite de tests complète (1539/1540, seul échec préexistant sans rapport) verte après correctif. + +--- + +## 🔴 2. Vulnérabilités confirmées (exécution réelle) + +### #1 — `asyncio.create_subprocess_exec`/`_shell` : exécution de commande arbitraire +**Sévérité : Critique — ✅ Corrigé (voir §8)** + +`asyncio` n'apparaît dans **aucune** des deux listes de modules interdits (`DEFAULT_FORBIDDEN` du scanner statique, `_FORBIDDEN_MODULES` du guard runtime — `xcore/kernel/security/section.py` / `xcore/kernel/sandbox/worker.py`). Un plugin sandboxé peut donc faire : + +```python +import asyncio +proc = await asyncio.create_subprocess_exec("id", stdout=-1) +out, _ = await proc.communicate() +``` + +**Résultat obtenu en conditions réelles** : +```json +{"status": "VULNERABLE", "output": "uid=1000(eliezer) gid=1000(eliezer) groups=1000(eliezer),957(ollama),958(docker),998(wheel)\n"} +``` +Le plugin a exécuté `id` sur la machine hôte et lu sa sortie — accès complet à n'importe quel binaire (`sh -c "..."` via `create_subprocess_shell` a été testé avec le même résultat). + +### #2 — `().__class__.__bases__[0].__subclasses__()` → classe `Popen` : évasion classique Python +**Sévérité : Critique — ✅ Corrigé (voir §8)** + +Le scanner AST statique bloque bien l'écriture **littérale** de `__subclasses__`/`__globals__`/`__class__` dans le code source (`FORBIDDEN_ATTRIBUTES`, `xcore/kernel/security/section.py:26-37`). Mais ces noms peuvent être construits dynamiquement à l'exécution (`operator.attrgetter("__" + "subclasses" + "__")`), invisibles pour une analyse par nœud AST. En remontant l'arbre d'héritage depuis n'importe quel objet, on retrouve `subprocess.Popen` — déjà chargé en mémoire par l'interpréteur avant même que le plugin ne démarre, donc jamais intercepté par le guard d'import : + +```python +base = attrgetter("__class__")(()).__bases__[0] +popen_cls = next(c for c in attrgetter("__subclasses__")(base)() if c.__name__ == "Popen") +p = popen_cls(["id"], stdout=-1) +``` + +**Résultat obtenu** : `{"status": "VULNERABLE", "output": "uid=1000(eliezer)...\n"}` — identique à #1, sans jamais faire `import subprocess` ni `import asyncio`. + +**Note méthodologique** : ce vecteur a nécessité de contourner deux faux positifs sympathiques du scanner (documentés en §4) avant d'atteindre le vrai test — un nom de variable local `imp` a été détecté comme le module `imp`, et `asyncio.subprocess.PIPE` a été détecté comme un accès au module `subprocess` via son nom d'attribut. + +### #3 — `import posix` (dynamique) : contournement du blocage de `os` +**Sévérité : Critique — ✅ Corrigé (voir §8)** + +`pwd`, `grp` et `posix` figurent dans la liste du scanner statique (`DEFAULT_FORBIDDEN`) mais **pas** dans celle du guard runtime (`_FORBIDDEN_MODULES`) — écart découvert en comparant programmatiquement les deux sets (54 vs 36 entrées, diff ci-dessous). `posix` est le module bas niveau sur lequel `os` est construit sur Unix ; il expose `fork`, `execve`, `kill`, etc. — un accès système quasi identique à `os`, jamais bloqué au runtime : + +```python +mod = dynamic_import("posix") +pid = mod.fork() +``` + +**Résultat obtenu** : `{"status": "VULNERABLE", "forked_pid": 556255, "wait_status": 0}` — fork réussi, process enfant créé sur l'hôte. + +**Diff exact des deux listes** (`DEFAULT_FORBIDDEN - _FORBIDDEN_MODULES`, modules réels seulement, hors builtins comme `eval`/`exec` qui sont gérés séparément par `FORBIDDEN_BUILTINS`) : `grp`, `posix`, `pwd`. + +--- + +## ✅ 3. Protections confirmées efficaces (contrôles positifs) + +| Protection | Test effectué | Résultat | +|---|---|---| +| `FilesystemGuard` — lecture hors `allowed_paths` | `open("/etc/passwd")` | `PermissionError: [sandbox] open('/etc/passwd') interdit` | +| `FilesystemGuard` — traversée de répertoire | `open("../../../tmp/xcore_sandbox_escape_test.txt", "w")` | Bloqué, même résultat | +| `FilesystemGuard` — écriture autorisée | `open("data/ok.txt", "w")` | `ok_as_expected` — pas de faux positif | +| Guard runtime — import obfusqué de module **listé** | `__import__` construit dynamiquement pour `os`/`ctypes` (contournant le scan statique) | `PermissionError: [sandbox] __import__('os') interdit` — le guard vérifie le **nom résolu**, pas le texte source | +| Limite mémoire (`RLIMIT_DATA`+`RLIMIT_RSS`, `worker.py::_apply_resource_limits`) | Allocation de 2M dicts avec limite à 50MB | `MemoryError` immédiate — **efficace**, contrairement à l'hypothèse initiale que `RLIMIT_RSS` serait un no-op sur Linux moderne (vérifiée fausse par test isolé) | +| `PermissionEngine` IPC (deny-by-default) | Appel sans `permissions:` déclarées dans `plugin.yaml` | Refusé avant même d'atteindre le sandbox — bonne défense en profondeur en amont | +| Scanner AST statique | `import os` littéral dans un plugin sandboxed | Chargement refusé (`scan failed`), le worker ne démarre même pas | + +--- + +## ⚠️ 4. Constats secondaires + +- **Le scanner statique est plus agressif que documenté** — il bloque un nom de **variable locale** coïncidant avec un nom de module interdit (`imp = ...` a été détecté comme le module `imp`), et un **attribut** dont le nom correspond à un module interdit même sur un objet légitime (`asyncio.subprocess.PIPE` détecté via `.subprocess`). Ce n'est pas un défaut de sécurité (fail-closed), mais une source probable de faux positifs frustrants pour des auteurs de plugins légitimes — à documenter dans `doc/security/security.md`. +- **`MemoryLimiter` (`xcore/kernel/sandbox/isolation.py:55-73`, `RLIMIT_AS`) est du code mort** — jamais appelé nulle part dans le runtime (`grep` confirmé). La vraie limite appliquée est `worker.py::_apply_resource_limits()` (`RLIMIT_DATA`+`RLIMIT_RSS`), qui s'avère efficace empiriquement mais duplique une seconde implémentation jamais utilisée. +- **Une bombe mémoire tue le worker entier**, pas seulement l'action en cours — le process sandboxé crashe (`EOF inattendu sur stdout`), déclenchant `_handle_crash()`/le mécanisme de retry. Comportement sûr au niveau isolation (le process malveillant meurt), mais pas gracieux au niveau applicatif (toute requête en vol sur ce worker échoue, pas seulement celle qui a déclenché la bombe). + +--- + +## 🧭 5. Limite structurelle à documenter clairement + +Le sandbox XCore est un **sandbox applicatif en pur Python**, dans le même process/espace mémoire que l'interpréteur hôte. Cette catégorie de sandbox a une limite fondamentale, indépendante de la qualité de l'implémentation : *tout objet système déjà chargé en mémoire par l'interpréteur reste atteignable par introspection* (technique `__subclasses__()`, connue et documentée depuis des années dans la communauté sécurité Python — ce n'est pas une découverte originale de cette analyse, mais sa confirmation empirique contre XCore l'est). + +`doc/security/security.md` présente actuellement le sandbox sans cette réserve. Recommandation : ajouter une section "Limites connues" explicite — le sandbox protège contre des plugins tiers **négligents ou peu sophistiqués**, pas contre un attaquant qui connaît les techniques classiques d'évasion. Pour une isolation réellement étanche contre un adversaire actif, il faudrait un sandboxing niveau OS en complément (namespaces Linux, seccomp-bpf, gVisor, conteneur dédié par plugin) — hors scope d'un correctif de code. + +--- + +## 🔧 6. Pistes de remédiation (non implémentées — à discuter) + +| # | Fix | Effort | Couvre | +|---|---|---|---| +| 1 | Ajouter `pwd`, `grp`, `posix` à `_FORBIDDEN_MODULES` (`worker.py`) | Trivial | #3 entièrement | +| 2 | Patcher spécifiquement `asyncio.subprocess.create_subprocess_exec`/`_shell`/`create_subprocess_transport` dans `FilesystemGuard._install_impl()` (le module `asyncio` lui-même doit rester utilisable — c'est le runtime du worker) | Faible | #1 | +| 3 | Patcher `subprocess.Popen.__init__` et `os.posix_spawn`/`os.fork`/`os.execve` directement (bloquer l'**exécution**, pas seulement l'**import**) — robuste même via `__subclasses__()` puisque la classe elle-même refuse d'agir | Moyen | #1, #2, #3 en une seule couche, plus robuste que des correctifs au cas par cas | +| 4 | Documenter la limite structurelle en §5 dans `doc/security/security.md` | Trivial | Communication/attentes, pas un fix technique | +| 5 | (Plus lourd, hors scope immédiat) Sandboxing niveau OS pour les déploiements à forte exigence d'isolation | Élevé | Seule vraie garantie contre un attaquant sophistiqué | + +Item 3 est la remédiation la plus robuste : elle ferme #1/#2/#3 par un seul mécanisme (bloquer au niveau de l'appel système réel, pas de la résolution de nom de module), et résiste par construction à toute future variante du même contournement. + +--- + +## 📌 7. Recommandation process + +Le rapport `technical_debt_remediation_verification_2026-08-11.md` recommandait déjà de revérifier tout rapport d'audit contre le code avant d'en faire un ticket. Ici c'est l'inverse qui s'est produit et qui vaut d'être noté : **l'hypothèse initiale sur `RLIMIT_RSS` (théoriquement un no-op sur Linux moderne) s'est révélée fausse à l'exécution** — la seule façon de le savoir était de tester, pas de lire le code. Les 3 vulnérabilités listées en §2, à l'inverse, semblaient improbables en lisant seulement les 36 entrées de `_FORBIDDEN_MODULES` (une liste qui semble complète) — ce n'est qu'en les exécutant réellement contre le vrai worker qu'elles se sont confirmées. Aucune des deux conclusions n'était devinable de manière fiable depuis la seule lecture statique. + +--- + +## ✅ 8. Vérification post-correctif + +Correctif appliqué dans `xcore/kernel/sandbox/worker.py` (item 1 + 2 simplifié + 3 du tableau §6, fusionnés en une seule couche) : +- `pwd`, `grp`, `posix` ajoutés à `_FORBIDDEN_MODULES` — ferme #3 au niveau import. +- Nouvelle « Couche 5 » dans `FilesystemGuard._install_impl()` : patch direct des objets déjà chargés en mémoire plutôt que du seul mécanisme d'import — `subprocess.Popen.__init__`, `subprocess.call`/`run`/`check_call`/`check_output`, toute la famille `os.fork`/`os.exec*`/`os.spawn*`/`os.posix_spawn*`/`os.system`/`os.popen`, et `asyncio.create_subprocess_exec`/`_shell` (module et sous-module `asyncio.subprocess`). Approche volontairement plus large que le strict minimum demandé par #1/#3 : elle ferme aussi #2 (`__subclasses__()` → `Popen`), qu'un simple ajout à la liste de modules interdits n'aurait pas pu fermer puisqu'aucun `import` n'y est jamais exécuté. + +**Les 3 PoC du §2 ré-exécutés après correctif** : + +```json +{ + "try_asyncio_subprocess": {"status": "blocked", "error": "PermissionError: [sandbox] asyncio.create_subprocess_exec/_shell() interdit dans le sandbox"}, + "try_subclasses_popen_exec": {"status": "blocked", "error": "PermissionError: [sandbox] subprocess.Popen() interdit dans le sandbox"}, + "try_posix_system": {"status": "blocked", "error": "PermissionError: [sandbox] __import__('posix') interdit dans le sandbox"} +} +``` + +**Non-régression vérifiée** : +- Usage légitime d'`asyncio` sans subprocess (`await asyncio.sleep(0.05)`) : toujours fonctionnel — `{"status": "ok_as_expected"}`. +- `FilesystemGuard` (lecture/écriture) : comportement inchangé. +- Suite de tests complète du projet : `1539 passed, 1 skipped` (le seul échec, `test_router_construit`, est le bug Studio préexistant sans rapport, déjà documenté ailleurs). + +**Ce qui reste ouvert** : le constat structurel du §5 (sandbox purement applicatif Python, limite fondamentale face à un attaquant qui découvrirait une *nouvelle* classe système exploitable déjà chargée) et l'item 4 du §6 (documenter cette limite dans `doc/security/security.md`) ne sont pas traités par ce correctif — celui-ci ferme les 3 vecteurs concrets trouvés, pas la catégorie de risque dans l'absolu. diff --git a/roadmap/ROADMAP_PROGRESS.md b/roadmap/ROADMAP_PROGRESS.md index 3909b6c..61387e3 100644 --- a/roadmap/ROADMAP_PROGRESS.md +++ b/roadmap/ROADMAP_PROGRESS.md @@ -98,18 +98,23 @@ This document outlines the current state of the XCore framework relative to the --- -## 🔍 Technical Analysis (Update v2.3.5) +## 🔍 Technical Analysis (Update v2.7.0) ### Strengths - **Advanced Runtime (V2)**: Support for ephemeral plugins with Warm Pool is a major technical achievement, enabling minimal "cold start" latency. - **Security & Performance**: Recent optimizations on the EventBus and the permission engine have successfully reduced latency on the critical path. - **Observability (V2)**: Real OpenTelemetry SDK + end-to-end W3C trace propagation (HTTP → plugin calls → sandbox IPC) now closes out V2's last two ⚠️ items. - **Tenancy (V3)**: Resource isolation (DB/Cache) per tenant is mature and fully validated by integration tests. +- **Plugin lifecycle hardening (V2, v2.6.0)**: persistent enable/disable state (`PluginStateStore`) that survives restarts, forced resource cleanup on unload (scheduler jobs, health checks, event/hook subscriptions, exported services — previously leaked silently), and an HTTP control surface (`/plugins/ipc/registry|enable|disable|audit|events`) with an IPC-call and event/hook audit trail. See `CHANGELOG.md` [2.6.0]. +- **Dependency hygiene (v2.7.0)**: core `dependencies` reduced to what `import xcore` and the zero-config boot path actually need; backend-specific packages (DB drivers, Redis, Celery, Alembic, OTLP exporter) moved to opt-in extras, closing a gap where `prometheus-client` was imported by core runtime code but only ever available via dev dependencies. See `CHANGELOG.md` [2.7.0]. ### Known limitations to track during the V2 maintenance window - **`self.tracer` / `self.metrics` / `self.health` are `None` inside ephemeral/sandboxed plugins** — no `PluginContext` is injected in `sandbox/worker.py`. Automatic supervisor-level tracing still covers those calls; only plugin-authored custom spans/metrics inside ephemeral code are affected. See `doc/observability/observability.md`. - **Tiered cache has no cross-node invalidation push** — L1 staleness is bounded by `ttl`, not eliminated. Acceptable trade-off, documented in `doc/services/cache.md`. +- **Sandbox escape confirmed via dynamic testing (2026-09-28)**: 3 working techniques let a `sandboxed` plugin run arbitrary commands on the host — `asyncio.create_subprocess_exec`/`_shell` (module never on any forbidden-module list), `().__class__.__bases__[0].__subclasses__()` walking to an already-loaded `subprocess.Popen` (defeats both the static AST scan and the runtime import guard, since no `import` statement is ever executed), and dynamic `import posix` (listed in the static scanner's forbidden set but missing from the runtime guard's — `posix` gives near-`os`-equivalent access). Memory limits (`RLIMIT_DATA`/`RLIMIT_RSS`) and the filesystem guard were verified effective by the same testing. See `reports/sandbox_dynamic_security_analysis_2026-09-28.md` for full detail and proposed fixes (none applied yet — pending a decision on scope). +- **`ExecutionMode.LEGACY` relevance**: functionally a pure alias of `TRUSTED` (`PluginLoader` registers the same `TrustedActivator()` for both), yet it is `PluginManifest.execution_mode`'s **default value** when a `plugin.yaml` omits the field — meaning an unspecified plugin silently gets full in-process trust rather than defaulting to the safer `sandboxed`. `LagacyActivator` (note the typo) exists but is dead code, raising `NotImplementedError` unconditionally. Recommendation: either retire `LEGACY` (rename references to `TRUSTED`, drop the dead activator) or keep it strictly as a documented historical alias with a startup warning nudging authors toward an explicit mode — but the current silent-default-to-full-trust behavior is worth a deliberate decision either way, not something to leave unexamined. ### High-Priority Workstreams (V3, once the maintenance window ends) 1. **Clustering (V3)**: This is the missing technological leap. The framework must support inter-node communication (Cluster IPC). 2. **Resilience (V3)**: Implement Circuit Breaker and Failover patterns for inter-plugin stability. +3. **Sandbox hardening (carried over from V2 maintenance)**: apply the fixes from `reports/sandbox_dynamic_security_analysis_2026-09-28.md` — syncing the two forbidden-module lists is trivial, patching `subprocess.Popen.__init__`/`os.fork`/`os.execve` directly (rather than only gating imports) is the more robust fix that survives future variants of the same bypass class. diff --git a/roadmap/executed_roadmap.md b/roadmap/executed_roadmap.md index 2e1204a..8c20473 100644 --- a/roadmap/executed_roadmap.md +++ b/roadmap/executed_roadmap.md @@ -98,18 +98,23 @@ Ce document présente l'état actuel du framework XCore par rapport aux objectif --- -## 🔍 Analyse Technique (MàJ v2.3.5) +## 🔍 Analyse Technique (MàJ v2.7.0) ### Points Forts - **Runtime Avancé (V2)** : Le support des plugins éphémères avec Warm Pool est une réussite technique majeure, permettant des performances "cold start" minimales. - **Sécurité & Performance** : Les optimisations récentes sur l'EventBus et le moteur de permissions ont réduit la latence sur le chemin critique. - **Observabilité (V2)** : Vrai SDK OpenTelemetry + propagation W3C bout en bout (HTTP → appels plugins → IPC sandbox) — clôt les deux derniers ⚠️ de V2. - **Tenancy (V3)** : L'isolation des ressources (DB/Cache) par tenant est mature et validée par les tests d'intégration. +- **Durcissement du cycle de vie des plugins (V2, v2.6.0)** : état actif/inactif persistant (`PluginStateStore`) qui survit aux redémarrages, ramasse-miette forcé au unload (jobs scheduler, health checks, abonnements events/hooks, services exportés — fuyaient silencieusement auparavant), et une surface de contrôle HTTP (`/plugins/ipc/registry|enable|disable|audit|events`) avec journal d'audit des appels IPC et de l'activité events/hooks. Voir `CHANGELOG.md` [2.6.0]. +- **Hygiène des dépendances (v2.7.0)** : `dependencies` du noyau réduit à ce dont `import xcore` et le boot zero-config ont réellement besoin ; les paquets spécifiques à un backend (drivers DB, Redis, Celery, Alembic, exporteur OTLP) déplacés en extras optionnels — corrige au passage un trou où `prometheus-client` était importé par du code noyau mais uniquement disponible en dépendances dev. Voir `CHANGELOG.md` [2.7.0]. ### Limites connues à suivre pendant la fenêtre de maintenance V2 - **`self.tracer` / `self.metrics` / `self.health` valent `None` dans les plugins ephemeral/sandboxed** — aucun `PluginContext` injecté dans `sandbox/worker.py`. Le tracing automatique côté superviseur couvre quand même ces appels ; seuls les spans/métriques custom écrits dans le code d'un plugin ephemeral sont affectés. - **Le cache étagé n'a pas d'invalidation push inter-nœuds** — la fraîcheur du L1 est bornée par le `ttl`, pas garantie immédiate. Compromis assumé et documenté. +- **Évasion de sandbox confirmée par test dynamique (28/09/2026)** : 3 techniques fonctionnelles permettent à un plugin `sandboxed` d'exécuter des commandes arbitraires sur l'hôte — `asyncio.create_subprocess_exec`/`_shell` (module absent de toute liste noire), `().__class__.__bases__[0].__subclasses__()` remontant vers un `subprocess.Popen` déjà chargé (contourne à la fois le scan AST statique et le guard d'import runtime, puisqu'aucun `import` n'est jamais exécuté), et `import posix` dynamique (listé côté scanner statique mais absent du guard runtime — `posix` donne un accès quasi équivalent à `os`). Les limites mémoire (`RLIMIT_DATA`/`RLIMIT_RSS`) et le filesystem guard ont en revanche été vérifiés efficaces par les mêmes tests. Détail complet et pistes de correction dans `reports/sandbox_dynamic_security_analysis_2026-09-28.md` (rien d'appliqué pour l'instant — décision de portée en attente). +- **Pertinence de `ExecutionMode.LEGACY`** : c'est fonctionnellement un pur alias de `TRUSTED` (`PluginLoader` enregistre le même `TrustedActivator()` pour les deux), et pourtant c'est la **valeur par défaut** de `PluginManifest.execution_mode` quand un `plugin.yaml` omet le champ — un plugin non spécifié obtient donc silencieusement la confiance in-process complète plutôt que de retomber sur `sandboxed`, plus sûr par défaut. `LagacyActivator` (coquille dans le nom) existe mais est du code mort, levant `NotImplementedError` inconditionnellement. Recommandation : soit retirer `LEGACY` (renommer les références vers `TRUSTED`, supprimer l'activateur mort), soit le garder strictement comme alias historique documenté avec un avertissement au chargement incitant à choisir un mode explicite — mais le comportement actuel (défaut silencieux vers la confiance complète) mérite une décision délibérée, pas de rester tel quel sans examen. ### Chantiers Prioritaires (V3, une fois la fenêtre de maintenance terminée) 1. **Clustering (V3)** : C'est le saut technologique manquant. Le framework doit pouvoir communiquer entre nœuds (Cluster IPC). 2. **Résilience (V3)** : Implémenter Circuit Breaker et Failover pour la stabilité inter-plugins. +3. **Durcissement du sandbox (reporté de la maintenance V2)** : appliquer les correctifs de `reports/sandbox_dynamic_security_analysis_2026-09-28.md` — synchroniser les deux listes de modules interdits est trivial ; patcher directement `subprocess.Popen.__init__`/`os.fork`/`os.execve` (plutôt que de seulement filtrer les imports) est le correctif le plus robuste, qui résiste par construction aux futures variantes du même type de contournement. diff --git a/xcore/__version__.py b/xcore/__version__.py index 0be1fae..050fd25 100644 --- a/xcore/__version__.py +++ b/xcore/__version__.py @@ -1,4 +1,4 @@ -__version__ = "2.6.0" -__version_info__ = (2, 6, 0) +__version__ = "2.6.1" +__version_info__ = (2, 6, 1) __author__ = "xcore contributors" __license__ = "MIT" diff --git a/xcore/kernel/sandbox/worker.py b/xcore/kernel/sandbox/worker.py index cab666e..81c70c3 100644 --- a/xcore/kernel/sandbox/worker.py +++ b/xcore/kernel/sandbox/worker.py @@ -72,6 +72,74 @@ def _apply_resource_limits() -> None: builtins_open = _builtins_module.open +def _install_subprocess_guard(block) -> None: + """ + Bloque la création de subprocess quel que soit le chemin emprunté pour + y accéder — pas seulement via `import` (couche 5 du sandbox). + + Patche directement les objets déjà en mémoire (`subprocess.Popen`, + `os.fork`/`exec*`/`spawn*`, `asyncio.create_subprocess_*`) plutôt que de + ne filtrer que les imports par nom : une classe déjà chargée par + l'interpréteur (ex. `subprocess.Popen`, atteignable sans jamais exécuter + `import subprocess` via `().__class__.__bases__[0].__subclasses__()`) + resterait sinon exploitable même avec `subprocess` dans la liste des + modules interdits. `block(label, *args)` est le callback `_block` de + `FilesystemGuard._install_impl()` (log + lève `PermissionError`). + """ + import asyncio as _asyncio + import subprocess as _subprocess + + def _blocked_spawn(label): + def _inner(*args, **kwargs): + block(f"{label}()", args) + + return _inner + + def _blocked_popen_init(self, *args, **kwargs): + block("subprocess.Popen()", args) + + _subprocess.Popen.__init__ = _blocked_popen_init + _subprocess.call = _blocked_spawn("subprocess.call") + _subprocess.run = _blocked_spawn("subprocess.run") + _subprocess.check_call = _blocked_spawn("subprocess.check_call") + _subprocess.check_output = _blocked_spawn("subprocess.check_output") + + for _name in ( + "fork", + "forkpty", + "system", + "popen", + "posix_spawn", + "posix_spawnp", + "execl", + "execle", + "execlp", + "execlpe", + "execv", + "execve", + "execvp", + "execvpe", + "spawnl", + "spawnle", + "spawnlp", + "spawnlpe", + "spawnv", + "spawnve", + "spawnvp", + "spawnvpe", + ): + if hasattr(os, _name): + setattr(os, _name, _blocked_spawn(f"os.{_name}")) + + async def _blocked_create_subprocess(*args, **kwargs): + block("asyncio.create_subprocess_exec/_shell()", args) + + _asyncio.create_subprocess_exec = _blocked_create_subprocess + _asyncio.create_subprocess_shell = _blocked_create_subprocess + _asyncio.subprocess.create_subprocess_exec = _blocked_create_subprocess + _asyncio.subprocess.create_subprocess_shell = _blocked_create_subprocess + + class FilesystemGuard: """ Applique la politique filesystem déclarée dans le manifeste. @@ -304,6 +372,9 @@ def __init__(self, file, *args, **kwargs): _FORBIDDEN_MODULES = frozenset( { "os", + "posix", # module bas niveau sous os sur Unix — accès quasi équivalent + "pwd", + "grp", "sys", "subprocess", "shutil", @@ -438,6 +509,16 @@ def _inner(*args, **kwargs): with contextlib.suppress(AttributeError): _ctypes.cdll.LoadLibrary = _blocked_ctypes_api("cdll.LoadLibrary") + # ── Couche 5 : création de subprocess — blocage des primitives ──────── + # Complète les couches 1-4 : bloquer l'IMPORT d'un module ne suffit pas + # si la classe/fonction dangereuse est déjà chargée en mémoire par + # l'interpréteur (ex: `().__class__.__bases__[0].__subclasses__()` + # retrouve `subprocess.Popen` sans jamais exécuter `import subprocess`) + # ou si elle vit dans un module légitime qu'on ne peut pas bloquer en + # entier (`asyncio` sert au worker lui-même pour sa boucle IPC — seules + # ses fonctions de spawn de subprocess sont dangereuses). + _install_subprocess_guard(_block) + def uninstall(self) -> None: """Restaure les builtins originaux (utile pour les tests).""" import builtins From 589e6e950fc93c7db253b63a97bb7b4d5d0a6bc3 Mon Sep 17 00:00:00 2001 From: traoreera Date: Mon, 28 Sep 2026 16:04:39 +0000 Subject: [PATCH 03/15] =?UTF-8?q?docs(security):=20anonymise=20le=20nom=20?= =?UTF-8?q?d'utilisateur=20syst=C3=A8me=20dans=20le=20rapport?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les exemples de sortie de `id` dans le rapport exposaient mon nom d'utilisateur système réel et mes groupes (eliezer, docker, ollama...) — remplacés par des valeurs génériques avant publication sur le dépôt public. --- reports/sandbox_dynamic_security_analysis_2026-09-28.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/reports/sandbox_dynamic_security_analysis_2026-09-28.md b/reports/sandbox_dynamic_security_analysis_2026-09-28.md index 4a906a2..f4ba5e0 100644 --- a/reports/sandbox_dynamic_security_analysis_2026-09-28.md +++ b/reports/sandbox_dynamic_security_analysis_2026-09-28.md @@ -35,7 +35,7 @@ out, _ = await proc.communicate() **Résultat obtenu en conditions réelles** : ```json -{"status": "VULNERABLE", "output": "uid=1000(eliezer) gid=1000(eliezer) groups=1000(eliezer),957(ollama),958(docker),998(wheel)\n"} +{"status": "VULNERABLE", "output": "uid=1000(users) gid=1000(users) groups=1000(users),9537(lib23),9558(docker),998(wheel)\n"} ``` Le plugin a exécuté `id` sur la machine hôte et lu sa sortie — accès complet à n'importe quel binaire (`sh -c "..."` via `create_subprocess_shell` a été testé avec le même résultat). @@ -50,7 +50,7 @@ popen_cls = next(c for c in attrgetter("__subclasses__")(base)() if c.__name__ = p = popen_cls(["id"], stdout=-1) ``` -**Résultat obtenu** : `{"status": "VULNERABLE", "output": "uid=1000(eliezer)...\n"}` — identique à #1, sans jamais faire `import subprocess` ni `import asyncio`. +**Résultat obtenu** : `{"status": "VULNERABLE", "output": "uid=1000(users)...\n"}` — identique à #1, sans jamais faire `import subprocess` ni `import asyncio`. **Note méthodologique** : ce vecteur a nécessité de contourner deux faux positifs sympathiques du scanner (documentés en §4) avant d'atteindre le vrai test — un nom de variable local `imp` a été détecté comme le module `imp`, et `asyncio.subprocess.PIPE` a été détecté comme un accès au module `subprocess` via son nom d'attribut. From 041eed74e836d0a3bf5bb6c69ce58020816f7fce Mon Sep 17 00:00:00 2001 From: traoreera Date: Mon, 28 Sep 2026 16:14:42 +0000 Subject: [PATCH 04/15] =?UTF-8?q?fix(security):=20execution=5Fmode=20par?= =?UTF-8?q?=20d=C3=A9faut=20passe=20de=20legacy=20=C3=A0=20sandboxed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit legacy est fonctionnellement un pur alias de trusted (PluginLoader enregistre le même TrustedActivator() pour les deux) — un plugin.yaml qui omet execution_mode obtenait donc silencieusement la confiance in-process complète (aucune restriction AST, aucun filesystem guard, accès direct à tous les services) plutôt que le mode le plus restrictif. Signalé dans reports/sandbox_dynamic_security_analysis_2026-09-28.md et roadmap/ROADMAP_PROGRESS.md comme un défaut fail-open méritant une décision délibérée — décision prise : fail-closed. - xcore/kernel/security/validation.py : ManifestValidator.load_and_validate, le vrai point de résolution du défaut pour un plugin.yaml chargé ("legacy" → "sandboxed") - xcore/sdk/plugin_base.py : PluginManifest.execution_mode, défaut du dataclass (utilisé si construit hors du pipeline de validation) - xcore/sdk/manifest_schema.json : default du schéma + ajoute "ephemeral", absent de l'enum documenté alors que le code le supporte ExecutionMode.LEGACY lui-même n'est pas retiré — reste utilisable explicitement (execution_mode: legacy), seul le défaut implicite change. BREAKING : tout plugin existant qui comptait sur l'accès in-process implicite doit désormais déclarer execution_mode: trusted (ou legacy) explicitement dans son plugin.yaml, sous peine de charger en sandboxed et d'échouer sur des imports/accès filesystem auparavant tenus pour acquis. Deux tests mis à jour pour refléter le nouveau défaut (test_base.py::test_minimal_manifest, test_manifest.py::test_basic_creation). 1539 tests passants (1 échec préexistant sans rapport), lint clean. Version : 2.6.1 → 2.6.2. --- CHANGELOG.md | 7 +++++++ doc/changelog.md | 7 +++++++ pyproject.toml | 2 +- roadmap/ROADMAP_PROGRESS.md | 5 ++--- roadmap/executed_roadmap.md | 5 ++--- tests/unit/plugins/test_base.py | 2 +- tests/unit/plugins/test_manifest.py | 2 +- xcore/__version__.py | 4 ++-- xcore/kernel/security/validation.py | 4 +++- xcore/sdk/manifest_schema.json | 4 ++-- xcore/sdk/plugin_base.py | 2 +- 11 files changed, 29 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f9be87..4ff4225 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.6.2] - 2026-09-28 + +### Security +- **BREAKING: a plugin whose `plugin.yaml` omits `execution_mode` now defaults to `sandboxed` instead of `legacy`.** `legacy` is functionally a pure alias of `trusted` (`PluginLoader` registers the same `TrustedActivator()` for both) — meaning an unspecified plugin was silently getting full in-process trust (no AST scan restrictions, no filesystem guard, direct access to every service) rather than the safer, more restrictive default. Flagged in `reports/sandbox_dynamic_security_analysis_2026-09-28.md` / `roadmap/ROADMAP_PROGRESS.md` as a fail-open default worth a deliberate decision; the decision is fail-closed. Changed in `xcore/kernel/security/validation.py` (`ManifestValidator.load_and_validate`, the actual resolution path for a loaded plugin.yaml), `xcore/sdk/plugin_base.py` (`PluginManifest.execution_mode` dataclass default), and `xcore/sdk/manifest_schema.json` (schema default + adds the previously-missing `ephemeral` to the documented enum). + **Migration**: any existing plugin relying on the implicit in-process default must now declare `execution_mode: trusted` (or `legacy`) explicitly in its `plugin.yaml`, or it will load as `sandboxed` and may fail on blocked imports/filesystem access it previously took for granted. +- `ExecutionMode.LEGACY` itself is unchanged and still resolves to `TrustedActivator()` when explicitly requested — only the *implicit* default moved. + ## [2.6.1] - 2026-09-28 ### Security diff --git a/doc/changelog.md b/doc/changelog.md index 0f9be87..4ff4225 100644 --- a/doc/changelog.md +++ b/doc/changelog.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.6.2] - 2026-09-28 + +### Security +- **BREAKING: a plugin whose `plugin.yaml` omits `execution_mode` now defaults to `sandboxed` instead of `legacy`.** `legacy` is functionally a pure alias of `trusted` (`PluginLoader` registers the same `TrustedActivator()` for both) — meaning an unspecified plugin was silently getting full in-process trust (no AST scan restrictions, no filesystem guard, direct access to every service) rather than the safer, more restrictive default. Flagged in `reports/sandbox_dynamic_security_analysis_2026-09-28.md` / `roadmap/ROADMAP_PROGRESS.md` as a fail-open default worth a deliberate decision; the decision is fail-closed. Changed in `xcore/kernel/security/validation.py` (`ManifestValidator.load_and_validate`, the actual resolution path for a loaded plugin.yaml), `xcore/sdk/plugin_base.py` (`PluginManifest.execution_mode` dataclass default), and `xcore/sdk/manifest_schema.json` (schema default + adds the previously-missing `ephemeral` to the documented enum). + **Migration**: any existing plugin relying on the implicit in-process default must now declare `execution_mode: trusted` (or `legacy`) explicitly in its `plugin.yaml`, or it will load as `sandboxed` and may fail on blocked imports/filesystem access it previously took for granted. +- `ExecutionMode.LEGACY` itself is unchanged and still resolves to `TrustedActivator()` when explicitly requested — only the *implicit* default moved. + ## [2.6.1] - 2026-09-28 ### Security diff --git a/pyproject.toml b/pyproject.toml index 38da29c..4b0593b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "XCoreRuntime" -version = "2.6.1" +version = "2.6.2" description = "Plugin-first orchestration framework built on FastAPI" authors = [ { name = "Eliezer Traore", email = "68350805+traoreera@users.noreply.github.com" }, diff --git a/roadmap/ROADMAP_PROGRESS.md b/roadmap/ROADMAP_PROGRESS.md index 61387e3..b229856 100644 --- a/roadmap/ROADMAP_PROGRESS.md +++ b/roadmap/ROADMAP_PROGRESS.md @@ -107,14 +107,13 @@ This document outlines the current state of the XCore framework relative to the - **Tenancy (V3)**: Resource isolation (DB/Cache) per tenant is mature and fully validated by integration tests. - **Plugin lifecycle hardening (V2, v2.6.0)**: persistent enable/disable state (`PluginStateStore`) that survives restarts, forced resource cleanup on unload (scheduler jobs, health checks, event/hook subscriptions, exported services — previously leaked silently), and an HTTP control surface (`/plugins/ipc/registry|enable|disable|audit|events`) with an IPC-call and event/hook audit trail. See `CHANGELOG.md` [2.6.0]. - **Dependency hygiene (v2.7.0)**: core `dependencies` reduced to what `import xcore` and the zero-config boot path actually need; backend-specific packages (DB drivers, Redis, Celery, Alembic, OTLP exporter) moved to opt-in extras, closing a gap where `prometheus-client` was imported by core runtime code but only ever available via dev dependencies. See `CHANGELOG.md` [2.7.0]. +- **Sandbox hardening (v2.6.1/v2.6.2)**: 3 confirmed sandbox-escape techniques (`asyncio.create_subprocess_exec`/`_shell`, `__subclasses__()` walk to an already-loaded `subprocess.Popen`, dynamic `import posix`) found via dynamic testing and fixed by patching the dangerous objects directly rather than only gating imports by name — see `reports/sandbox_dynamic_security_analysis_2026-09-28.md`. Separately, an unspecified `execution_mode` in `plugin.yaml` now defaults to `sandboxed` instead of the `trusted`-equivalent `legacy` — fail-closed instead of fail-open. See `CHANGELOG.md` [2.6.1]/[2.6.2]. ### Known limitations to track during the V2 maintenance window - **`self.tracer` / `self.metrics` / `self.health` are `None` inside ephemeral/sandboxed plugins** — no `PluginContext` is injected in `sandbox/worker.py`. Automatic supervisor-level tracing still covers those calls; only plugin-authored custom spans/metrics inside ephemeral code are affected. See `doc/observability/observability.md`. - **Tiered cache has no cross-node invalidation push** — L1 staleness is bounded by `ttl`, not eliminated. Acceptable trade-off, documented in `doc/services/cache.md`. -- **Sandbox escape confirmed via dynamic testing (2026-09-28)**: 3 working techniques let a `sandboxed` plugin run arbitrary commands on the host — `asyncio.create_subprocess_exec`/`_shell` (module never on any forbidden-module list), `().__class__.__bases__[0].__subclasses__()` walking to an already-loaded `subprocess.Popen` (defeats both the static AST scan and the runtime import guard, since no `import` statement is ever executed), and dynamic `import posix` (listed in the static scanner's forbidden set but missing from the runtime guard's — `posix` gives near-`os`-equivalent access). Memory limits (`RLIMIT_DATA`/`RLIMIT_RSS`) and the filesystem guard were verified effective by the same testing. See `reports/sandbox_dynamic_security_analysis_2026-09-28.md` for full detail and proposed fixes (none applied yet — pending a decision on scope). -- **`ExecutionMode.LEGACY` relevance**: functionally a pure alias of `TRUSTED` (`PluginLoader` registers the same `TrustedActivator()` for both), yet it is `PluginManifest.execution_mode`'s **default value** when a `plugin.yaml` omits the field — meaning an unspecified plugin silently gets full in-process trust rather than defaulting to the safer `sandboxed`. `LagacyActivator` (note the typo) exists but is dead code, raising `NotImplementedError` unconditionally. Recommendation: either retire `LEGACY` (rename references to `TRUSTED`, drop the dead activator) or keep it strictly as a documented historical alias with a startup warning nudging authors toward an explicit mode — but the current silent-default-to-full-trust behavior is worth a deliberate decision either way, not something to leave unexamined. +- **`ExecutionMode.LEGACY` is still functionally a pure alias of `TRUSTED`**, and can still be requested explicitly (`execution_mode: legacy` in `plugin.yaml`) — only the *implicit* default changed (v2.6.2), the enum value itself was not retired. `LagacyActivator` (note the typo) remains dead code, raising `NotImplementedError` unconditionally — harmless (never instantiated: `PluginLoader` maps `ExecutionMode.LEGACY` to `TrustedActivator()`) but worth deleting in a future cleanup pass. ### High-Priority Workstreams (V3, once the maintenance window ends) 1. **Clustering (V3)**: This is the missing technological leap. The framework must support inter-node communication (Cluster IPC). 2. **Resilience (V3)**: Implement Circuit Breaker and Failover patterns for inter-plugin stability. -3. **Sandbox hardening (carried over from V2 maintenance)**: apply the fixes from `reports/sandbox_dynamic_security_analysis_2026-09-28.md` — syncing the two forbidden-module lists is trivial, patching `subprocess.Popen.__init__`/`os.fork`/`os.execve` directly (rather than only gating imports) is the more robust fix that survives future variants of the same bypass class. diff --git a/roadmap/executed_roadmap.md b/roadmap/executed_roadmap.md index 8c20473..24d33c2 100644 --- a/roadmap/executed_roadmap.md +++ b/roadmap/executed_roadmap.md @@ -107,14 +107,13 @@ Ce document présente l'état actuel du framework XCore par rapport aux objectif - **Tenancy (V3)** : L'isolation des ressources (DB/Cache) par tenant est mature et validée par les tests d'intégration. - **Durcissement du cycle de vie des plugins (V2, v2.6.0)** : état actif/inactif persistant (`PluginStateStore`) qui survit aux redémarrages, ramasse-miette forcé au unload (jobs scheduler, health checks, abonnements events/hooks, services exportés — fuyaient silencieusement auparavant), et une surface de contrôle HTTP (`/plugins/ipc/registry|enable|disable|audit|events`) avec journal d'audit des appels IPC et de l'activité events/hooks. Voir `CHANGELOG.md` [2.6.0]. - **Hygiène des dépendances (v2.7.0)** : `dependencies` du noyau réduit à ce dont `import xcore` et le boot zero-config ont réellement besoin ; les paquets spécifiques à un backend (drivers DB, Redis, Celery, Alembic, exporteur OTLP) déplacés en extras optionnels — corrige au passage un trou où `prometheus-client` était importé par du code noyau mais uniquement disponible en dépendances dev. Voir `CHANGELOG.md` [2.7.0]. +- **Durcissement du sandbox (v2.6.1/v2.6.2)** : 3 techniques d'évasion sandbox confirmées (`asyncio.create_subprocess_exec`/`_shell`, remontée `__subclasses__()` vers un `subprocess.Popen` déjà chargé, `import posix` dynamique) trouvées par test dynamique et corrigées en patchant directement les objets dangereux plutôt qu'en filtrant seulement les imports par nom — voir `reports/sandbox_dynamic_security_analysis_2026-09-28.md`. Par ailleurs, un `execution_mode` non précisé dans `plugin.yaml` retombe désormais sur `sandboxed` plutôt que sur `legacy` (équivalent à `trusted`) — fail-closed au lieu de fail-open. Voir `CHANGELOG.md` [2.6.1]/[2.6.2]. ### Limites connues à suivre pendant la fenêtre de maintenance V2 - **`self.tracer` / `self.metrics` / `self.health` valent `None` dans les plugins ephemeral/sandboxed** — aucun `PluginContext` injecté dans `sandbox/worker.py`. Le tracing automatique côté superviseur couvre quand même ces appels ; seuls les spans/métriques custom écrits dans le code d'un plugin ephemeral sont affectés. - **Le cache étagé n'a pas d'invalidation push inter-nœuds** — la fraîcheur du L1 est bornée par le `ttl`, pas garantie immédiate. Compromis assumé et documenté. -- **Évasion de sandbox confirmée par test dynamique (28/09/2026)** : 3 techniques fonctionnelles permettent à un plugin `sandboxed` d'exécuter des commandes arbitraires sur l'hôte — `asyncio.create_subprocess_exec`/`_shell` (module absent de toute liste noire), `().__class__.__bases__[0].__subclasses__()` remontant vers un `subprocess.Popen` déjà chargé (contourne à la fois le scan AST statique et le guard d'import runtime, puisqu'aucun `import` n'est jamais exécuté), et `import posix` dynamique (listé côté scanner statique mais absent du guard runtime — `posix` donne un accès quasi équivalent à `os`). Les limites mémoire (`RLIMIT_DATA`/`RLIMIT_RSS`) et le filesystem guard ont en revanche été vérifiés efficaces par les mêmes tests. Détail complet et pistes de correction dans `reports/sandbox_dynamic_security_analysis_2026-09-28.md` (rien d'appliqué pour l'instant — décision de portée en attente). -- **Pertinence de `ExecutionMode.LEGACY`** : c'est fonctionnellement un pur alias de `TRUSTED` (`PluginLoader` enregistre le même `TrustedActivator()` pour les deux), et pourtant c'est la **valeur par défaut** de `PluginManifest.execution_mode` quand un `plugin.yaml` omet le champ — un plugin non spécifié obtient donc silencieusement la confiance in-process complète plutôt que de retomber sur `sandboxed`, plus sûr par défaut. `LagacyActivator` (coquille dans le nom) existe mais est du code mort, levant `NotImplementedError` inconditionnellement. Recommandation : soit retirer `LEGACY` (renommer les références vers `TRUSTED`, supprimer l'activateur mort), soit le garder strictement comme alias historique documenté avec un avertissement au chargement incitant à choisir un mode explicite — mais le comportement actuel (défaut silencieux vers la confiance complète) mérite une décision délibérée, pas de rester tel quel sans examen. +- **`ExecutionMode.LEGACY` reste fonctionnellement un pur alias de `TRUSTED`**, et peut toujours être demandé explicitement (`execution_mode: legacy` dans `plugin.yaml`) — seul le défaut *implicite* a changé (v2.6.2), la valeur d'enum elle-même n'a pas été retirée. `LagacyActivator` (coquille dans le nom) reste du code mort, levant `NotImplementedError` inconditionnellement — sans danger (jamais instancié : `PluginLoader` fait pointer `ExecutionMode.LEGACY` vers `TrustedActivator()`) mais à supprimer dans un futur nettoyage. ### Chantiers Prioritaires (V3, une fois la fenêtre de maintenance terminée) 1. **Clustering (V3)** : C'est le saut technologique manquant. Le framework doit pouvoir communiquer entre nœuds (Cluster IPC). 2. **Résilience (V3)** : Implémenter Circuit Breaker et Failover pour la stabilité inter-plugins. -3. **Durcissement du sandbox (reporté de la maintenance V2)** : appliquer les correctifs de `reports/sandbox_dynamic_security_analysis_2026-09-28.md` — synchroniser les deux listes de modules interdits est trivial ; patcher directement `subprocess.Popen.__init__`/`os.fork`/`os.execve` (plutôt que de seulement filtrer les imports) est le correctif le plus robuste, qui résiste par construction aux futures variantes du même type de contournement. diff --git a/tests/unit/plugins/test_base.py b/tests/unit/plugins/test_base.py index 815cb6c..55cd2af 100644 --- a/tests/unit/plugins/test_base.py +++ b/tests/unit/plugins/test_base.py @@ -81,7 +81,7 @@ def test_minimal_manifest(self, tmp_path): assert manifest.name == "test_plugin" assert manifest.version == "1.0.0" - assert manifest.execution_mode == ExecutionMode.LEGACY + assert manifest.execution_mode == ExecutionMode.SANDBOXED def test_full_manifest(self, tmp_path): """Test creating full manifest.""" diff --git a/tests/unit/plugins/test_manifest.py b/tests/unit/plugins/test_manifest.py index 5ab7789..5d432b6 100644 --- a/tests/unit/plugins/test_manifest.py +++ b/tests/unit/plugins/test_manifest.py @@ -259,7 +259,7 @@ def test_basic_creation(self, tmp_path): assert manifest.description == "" assert manifest.framework_version == ">=2.0" assert manifest.entry_point == "src/main.py" - assert manifest.execution_mode == ExecutionMode.LEGACY + assert manifest.execution_mode == ExecutionMode.SANDBOXED def test_repr(self, tmp_path): """Test __repr__ method.""" diff --git a/xcore/__version__.py b/xcore/__version__.py index 050fd25..94d7bcf 100644 --- a/xcore/__version__.py +++ b/xcore/__version__.py @@ -1,4 +1,4 @@ -__version__ = "2.6.1" -__version_info__ = (2, 6, 1) +__version__ = "2.6.2" +__version_info__ = (2, 6, 2) __author__ = "xcore contributors" __license__ = "MIT" diff --git a/xcore/kernel/security/validation.py b/xcore/kernel/security/validation.py index 574f732..608f0f4 100644 --- a/xcore/kernel/security/validation.py +++ b/xcore/kernel/security/validation.py @@ -115,7 +115,9 @@ def load_and_validate(self, plugin_dir: Path): raw.get("framework_version", f"=={__version__}"), __version__ ) - raw_mode = raw.get("execution_mode", "legacy").lower() + # Fail-closed : un plugin qui ne déclare pas execution_mode obtient le + # mode le plus restrictif (sandboxed), pas un accès in-process complet. + raw_mode = raw.get("execution_mode", "sandboxed").lower() try: mode = ExecutionMode(raw_mode) except ValueError as e: diff --git a/xcore/sdk/manifest_schema.json b/xcore/sdk/manifest_schema.json index 9d427f1..89a16fc 100644 --- a/xcore/sdk/manifest_schema.json +++ b/xcore/sdk/manifest_schema.json @@ -14,8 +14,8 @@ "entry_point": { "type": "string", "default": "src/main.py" }, "execution_mode": { "type": "string", - "enum": ["trusted", "sandboxed", "legacy"], - "default": "legacy" + "enum": ["trusted", "sandboxed", "legacy", "ephemeral"], + "default": "sandboxed" }, "requires": { "type": "array", diff --git a/xcore/sdk/plugin_base.py b/xcore/sdk/plugin_base.py index e723645..c496ef0 100644 --- a/xcore/sdk/plugin_base.py +++ b/xcore/sdk/plugin_base.py @@ -205,7 +205,7 @@ class PluginManifest: description: str = "" framework_version: str = ">=2.0" entry_point: str = "src/main.py" - execution_mode: ExecutionMode = ExecutionMode.LEGACY + execution_mode: ExecutionMode = ExecutionMode.SANDBOXED # Dépendances et imports requires: list[PluginDependency] = field(default_factory=list) From 51fb834c3c4517127bfb896fa506aae656a31d7e Mon Sep 17 00:00:00 2001 From: traoreera Date: Mon, 28 Sep 2026 16:29:29 +0000 Subject: [PATCH 05/15] =?UTF-8?q?docs(roadmap):=20sp=C3=A9cification=20tec?= =?UTF-8?q?hnique=20V3=20=E2=80=94=20runtime=20natif=20distribu=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nouveau roadmap/V3_NATIVE_RUNTIME_SPEC.md : architecture cible pour V3 (Distribution) — Python reste le plan de contrôle (plugins, SDK, API, orchestration), un nouveau runtime natif Rust (xcore-runtime, embarqué via PyO3) prend en charge l'appartenance au cluster, le transport inter-nœuds, le routage, le volet distribué de XBus, le circuit breaker et le failover. Couvre : modèle de nœud, fédération statique, FederatedHandler, protocole de sérialisation versionné, sémantique de livraison des events (at-least-once), politique de retry/backpressure, frontière Python↔Rust (PyO3/maturin), stratégie de migration en 7 phases, definition of done. Rien n'est implémenté — c'est un document de spécification pour du travail qui commencera une fois la fenêtre de maintenance V2 terminée. Cross-référencé depuis la section V3 des deux fichiers roadmap (ROADMAP_PROGRESS.md / executed_roadmap.md), chaque ligne du tableau V3 correspond désormais à une section de la spec. --- roadmap/ROADMAP_PROGRESS.md | 2 + roadmap/V3_NATIVE_RUNTIME_SPEC.md | 1206 +++++++++++++++++++++++++++++ roadmap/executed_roadmap.md | 2 + 3 files changed, 1210 insertions(+) create mode 100644 roadmap/V3_NATIVE_RUNTIME_SPEC.md diff --git a/roadmap/ROADMAP_PROGRESS.md b/roadmap/ROADMAP_PROGRESS.md index 3909b6c..c2c687f 100644 --- a/roadmap/ROADMAP_PROGRESS.md +++ b/roadmap/ROADMAP_PROGRESS.md @@ -57,6 +57,8 @@ This document outlines the current state of the XCore framework relative to the ## 🌐 V3 — Distribution **Goal: Scale out beyond a single process.** +**Technical specification (2026-09-28)**: `roadmap/V3_NATIVE_RUNTIME_SPEC.md` — Python stays the control plane (plugins, SDK, API, orchestration); a new Rust native runtime (`xcore-runtime`, embedded via PyO3) owns cluster membership, inter-node transport, routing, the distributed side of XBus, circuit breaking, and failover. Every row below maps to a section of that spec — none of it is implemented yet, this is the target architecture for when the V2 maintenance window ends. + | Feature | State | Location / Note | | :--- | :---: | :--- | | Static Federation | ❌ | Not implemented | diff --git a/roadmap/V3_NATIVE_RUNTIME_SPEC.md b/roadmap/V3_NATIVE_RUNTIME_SPEC.md new file mode 100644 index 0000000..a78b36b --- /dev/null +++ b/roadmap/V3_NATIVE_RUNTIME_SPEC.md @@ -0,0 +1,1206 @@ +# XCore V3 — Native Distributed Runtime + +**Status:** Specification +**Target:** XCore V3 +**Architecture:** Python + Rust +**Primary objective:** Distributed, robust and high-performance XCore runtime + +--- + +## 1. Vision + +XCore V3 introduces a native runtime layer designed to extend XCore from a single-process plugin runtime into a distributed execution platform. + +The objective is **not to rewrite XCore in Rust**. + +Python remains the primary language for: + +* plugins; +* SDK; +* application logic; +* API; +* configuration; +* orchestration; +* schemas; +* CLI; +* business logic; +* integrations. + +Rust becomes the native execution layer for operations where predictable latency, concurrency, fault isolation and network performance are critical. + +The resulting architecture is: + +```text + XCore + │ + ┌─────────────┴─────────────┐ + │ │ + Python Control Plane Native Runtime + │ │ + ┌──────┼────────┐ ┌──────┼──────────┐ + │ │ │ │ │ │ + Plugins API Orchestration IPC Events Routing + │ │ │ │ │ │ + └──────┴────────┘ ├── Cluster + ├── Failover + ├── Circuit Breaker + └── Transport +``` + +--- + +# 2. Architectural Principle + +The fundamental rule of V3 is: + +> **Python defines what XCore should execute. Rust determines how it is transported and executed across the runtime.** + +Python must not become responsible for: + +* cluster membership; +* node-to-node routing; +* low-level IPC transport; +* distributed event transport; +* connection lifecycle; +* retry storms; +* circuit state; +* failover; +* network serialization; +* low-level health monitoring. + +These responsibilities belong to the native runtime. + +--- + +# 3. Compatibility Principle + +V3 MUST preserve compatibility with existing Python plugins whenever possible. + +An existing plugin should continue to use APIs such as: + +```python +await ctx.ipc.call( + target="inventory", + action="reserve", + payload=data, +) +``` + +without knowing whether the target plugin is: + +```text +same process + ↓ +local process + ↓ +sandbox + ↓ +same node + ↓ +remote node + ↓ +remote cluster +``` + +The location of the target is an implementation detail of the runtime. + +--- + +# 4. Runtime Layers + +XCore V3 consists of two major execution layers. + +## 4.1 Python Control Plane + +The Python layer contains: + +### Kernel + +* plugin loader; +* lifecycle; +* registry; +* permissions; +* tenancy; +* configuration; +* schemas; +* middleware; +* application orchestration. + +### SDK + +* plugin API; +* decorators; +* actions; +* routers; +* schemas; +* event APIs; +* lifecycle APIs. + +### Application Layer + +* business logic; +* domain services; +* application plugins; +* integrations. + +### API Layer + +* FastAPI; +* HTTP endpoints; +* WebSocket interfaces; +* external integrations. + +Python remains the primary extension mechanism. + +--- + +# 5. Native Runtime + +The native runtime MUST be implemented in Rust. + +Suggested package: + +```text +xcore-runtime/ +├── core/ +├── ipc/ +├── transport/ +├── router/ +├── federation/ +├── cluster/ +├── events/ +├── resilience/ +├── discovery/ +├── serialization/ +├── telemetry/ +└── bindings/ +``` + +The native runtime must be usable: + +1. embedded inside the Python XCore process; +2. as a standalone runtime process; +3. as a node runtime in a distributed cluster. + +--- + +# 6. Native Runtime Responsibilities + +The Rust runtime is responsible for: + +* local IPC; +* remote IPC; +* transport; +* request routing; +* node discovery; +* cluster membership; +* federation; +* distributed events; +* connection pooling; +* request timeout; +* retries; +* circuit breakers; +* failover; +* health checks; +* backpressure; +* serialization; +* connection lifecycle; +* distributed telemetry propagation. + +--- + +# 7. IPC Architecture + +XCore V3 introduces a unified IPC abstraction. + +```text +Plugin A + │ + ▼ +XCore SDK + │ + ▼ +Native IPC Layer + │ + ├── Local Process + ├── Sandbox + ├── Local Node + └── Remote Node +``` + +The caller MUST NOT need to select the transport manually. + +Example: + +```python +result = await ctx.ipc.call( + target="stock", + action="reserve", + payload={ + "product_id": product_id, + "quantity": quantity, + }, +) +``` + +The runtime resolves the destination. + +--- + +# 8. IPC Routing + +Every IPC request contains a logical destination. + +```text +Request +├── request_id +├── caller +├── tenant_id +├── target +├── action +├── payload +├── timeout +├── trace_context +└── metadata +``` + +The router determines: + +```text +target + ↓ +plugin registry + ↓ +service location + ↓ +node + ↓ +transport + ↓ +plugin +``` + +Routing MUST be deterministic and tenant-aware. + +--- + +# 9. Tenant Isolation + +Tenant isolation is mandatory. + +Every distributed request MUST carry a tenant context when tenancy is enabled. + +```text +tenant_id + │ + ├── IPC + ├── routing + ├── events + ├── cache + ├── database + ├── scheduler + └── telemetry +``` + +A request MUST NOT be routed to another tenant context accidentally. + +The native runtime MUST treat tenant identity as security metadata, not merely application payload. + +The existing XCore multi-tenancy model remains the source of truth for Python services. V3 extends this isolation across distributed boundaries. The current roadmap already identifies comprehensive multi-tenancy as implemented at the local runtime level. + +--- + +# 10. Node Model + +Each XCore node represents one runtime instance. + +```text +Cluster +│ +├── Node A +│ ├── Python Runtime +│ └── Native Runtime +│ +├── Node B +│ ├── Python Runtime +│ └── Native Runtime +│ +└── Node C + ├── Python Runtime + └── Native Runtime +``` + +A node exposes: + +```text +NodeIdentity +├── node_id +├── cluster_id +├── runtime_version +├── capabilities +├── address +├── status +├── load +└── health +``` + +--- + +# 11. Cluster Membership + +The native runtime MUST maintain cluster membership. + +The membership subsystem is responsible for: + +* node registration; +* node discovery; +* node health; +* heartbeat; +* node expiration; +* node removal; +* capability discovery. + +A node MUST transition through explicit states: + +```text +JOINING + ↓ +READY + ↓ +DEGRADED + ↓ +UNAVAILABLE + ↓ +REMOVED +``` + +Cluster state MUST NOT depend on Python application code. + +--- + +# 12. Static Federation + +V3 initially supports static federation. + +Example: + +```yaml +cluster: + id: production + +nodes: + - id: node-01 + address: 10.0.0.10:9000 + + - id: node-02 + address: 10.0.0.11:9000 + + - id: node-03 + address: 10.0.0.12:9000 +``` + +Static federation is the first implementation stage. + +Dynamic discovery may be introduced later without changing the public IPC abstraction. + +--- + +# 13. FederatedHandler + +`FederatedHandler` provides a logical handler abstraction independent of physical location. + +```text +FederatedHandler + │ + ├── local handler + ├── local process + ├── remote node + └── fallback node +``` + +The handler MUST expose a consistent interface regardless of location. + +--- + +# 14. Inter-node Transport + +The transport layer MUST be implemented in Rust. + +Requirements: + +* asynchronous I/O; +* connection reuse; +* bounded buffers; +* configurable timeout; +* connection pooling; +* backpressure; +* cancellation; +* graceful shutdown; +* efficient binary serialization. + +The transport MUST avoid spawning a Python coroutine for every low-level network operation. + +--- + +# 15. Serialization + +V3 should introduce a versioned native wire protocol. + +```text +XCore Message +├── protocol_version +├── message_type +├── request_id +├── tenant_id +├── source +├── destination +├── trace_context +├── flags +└── payload +``` + +The protocol MUST support: + +* version negotiation; +* backward compatibility; +* request/response; +* events; +* errors; +* cancellation; +* metadata. + +The serialization format should prioritize: + +1. low latency; +2. low allocation; +3. compact payloads; +4. schema evolution. + +--- + +# 16. Distributed Event Bus + +The existing XBus remains the application-level event abstraction. + +V3 extends it to distributed nodes. + +```text + XBus + │ + ┌──────┴──────┐ + │ │ + Local Bus Distributed Bus + │ │ + Process Cluster +``` + +A plugin continues to publish: + +```python +await ctx.events.emit( + "stock.updated", + payload, +) +``` + +The runtime determines whether the event is: + +```text +local +``` + +or: + +```text +distributed +``` + +--- + +# 17. Event Routing + +Distributed events MUST support: + +* topic; +* tenant scope; +* source node; +* event ID; +* timestamp; +* trace context; +* delivery metadata. + +Example: + +```text +Event +├── event_id +├── topic +├── tenant_id +├── source +├── timestamp +├── trace_context +└── payload +``` + +Events MUST NOT accidentally cross tenant boundaries. + +--- + +# 18. Delivery Semantics + +V3 MUST explicitly define event delivery semantics. + +Initial implementation: + +```text +At-least-once delivery +``` + +Consumers MUST therefore be designed to tolerate duplicate delivery. + +The runtime should expose event identifiers allowing consumers to implement idempotency. + +Exactly-once semantics MUST NOT be assumed. + +--- + +# 19. Circuit Breaker + +Every remote communication path MUST support circuit breaking. + +State machine: + +```text +CLOSED + │ + │ failures + ▼ +OPEN + │ + │ timeout + ▼ +HALF_OPEN + │ + ├── success → CLOSED + │ + └── failure → OPEN +``` + +Circuit breaker parameters: + +```yaml +circuit_breaker: + failure_threshold: 5 + recovery_timeout: 10s + half_open_requests: 1 +``` + +The implementation belongs to the native runtime. + +--- + +# 20. Failover + +When a node becomes unavailable: + +```text +Node A + │ + X + │ +Router + │ + ├── Node B + └── Node C +``` + +The router MUST: + +1. detect failure; +2. stop sending traffic to the failed node; +3. open the circuit; +4. select an eligible node; +5. retry when safe; +6. restore the original route when the node recovers. + +Failover MUST respect: + +* tenant; +* service capability; +* routing policy; +* request idempotency; +* timeout; +* circuit state. + +--- + +# 21. Retry Policy + +Retries MUST NOT be unconditional. + +The runtime must distinguish: + +```text +retryable +non-retryable +``` + +Examples of potentially retryable failures: + +* connection reset; +* temporary node unavailable; +* timeout before request acceptance. + +Examples of non-retryable failures: + +* validation error; +* authorization failure; +* business error; +* malformed request. + +Retry configuration: + +```yaml +retry: + enabled: true + max_attempts: 3 + backoff: exponential + max_delay: 2s +``` + +The runtime MUST prevent retry amplification. + +--- + +# 22. Backpressure + +The native runtime MUST support bounded queues. + +```text +Producer + │ + ▼ +Bounded Queue + │ + ▼ +Transport +``` + +When the queue reaches capacity, the runtime must apply an explicit policy: + +```text +reject +block +drop +shed +``` + +The policy must never be implicit. + +--- + +# 23. Health Monitoring + +Native health monitoring operates independently of Python plugin code. + +Health checks include: + +* node availability; +* transport availability; +* connection state; +* event bus state; +* queue pressure; +* latency; +* error rate; +* resource pressure. + +Python-level plugin health checks remain supported. + +--- + +# 24. Observability + +V3 MUST preserve the existing OpenTelemetry model. + +The current XCore V2 implementation already supports real OpenTelemetry and W3C trace propagation across HTTP and sandbox IPC. + +V3 extends the same trace context across: + +```text +HTTP + ↓ +Python + ↓ +Native IPC + ↓ +Node A + ↓ +Node B + ↓ +Plugin +``` + +A single distributed operation MUST retain its trace identity. + +Required telemetry: + +* trace ID; +* span ID; +* node; +* plugin; +* tenant; +* action; +* latency; +* status; +* transport; +* retry count; +* circuit state. + +--- + +# 25. Security + +Native communication MUST be authenticated. + +The runtime MUST support: + +* node identity; +* authenticated connections; +* authorization; +* tenant validation; +* message integrity; +* protocol version validation. + +Cluster communication MUST NOT trust a node solely because it is reachable on the network. + +--- + +# 26. Plugin Location Transparency + +The plugin developer must not need to know where a plugin is running. + +```python +await ctx.ipc.call( + target="payment", + action="charge", + payload=data, +) +``` + +is the only abstraction required. + +The runtime may internally execute: + +```text +Plugin A + ↓ +Local IPC +``` + +or: + +```text +Plugin A + ↓ +Node Router + ↓ +Network + ↓ +Node B + ↓ +Plugin B +``` + +The public plugin API remains identical. + +--- + +# 27. Python ↔ Rust Boundary + +The boundary MUST be deliberately small. + +Python should communicate with Rust through a stable native API. + +Conceptually: + +```python +runtime = XCoreNativeRuntime() + +await runtime.ipc.call(...) +await runtime.events.publish(...) +await runtime.router.resolve(...) +``` + +The Rust implementation remains hidden behind this abstraction. + +Possible Python binding technology: + +```text +PyO3 +maturin +``` + +The binding layer MUST NOT expose internal Rust structures directly. + +--- + +# 28. Standalone Runtime Mode + +The native runtime should also support standalone operation. + +```text +Python XCore + │ + │ IPC + ▼ +XCore Native Runtime + │ + ├── cluster + ├── transport + ├── routing + └── events +``` + +This allows XCore to evolve from an embedded architecture toward a dedicated runtime without breaking plugins. + +--- + +# 29. Performance Requirements + +V3 is a performance-oriented release. + +The native runtime SHOULD target: + +* low and predictable IPC latency; +* high concurrent connection counts; +* bounded memory usage; +* zero unbounded queues; +* connection reuse; +* minimal serialization overhead; +* minimal Python ↔ Rust transitions; +* no blocking operations on async runtime threads. + +Performance MUST be measured through benchmarks rather than assumptions. + +Required benchmark categories: + +```text +local IPC +remote IPC +event publishing +event consumption +serialization +routing +failover +circuit breaker +10 / 100 / 1k / 10k concurrent requests +``` + +--- + +# 30. Memory Safety + +The native runtime MUST NOT introduce manual memory management into the core architecture. + +Rust ownership and borrowing rules are preferred over: + +* raw pointers; +* manual allocation; +* unsafe shared state. + +`unsafe` code MUST be isolated, documented and justified. + +--- + +# 31. Failure Model + +V3 assumes that failures are normal. + +The runtime MUST tolerate: + +* plugin crash; +* sandbox crash; +* Python process crash; +* node crash; +* network interruption; +* connection reset; +* timeout; +* event consumer failure; +* temporary overload. + +A failure in one node MUST NOT automatically terminate the entire cluster. + +--- + +# 32. Graceful Shutdown + +Shutdown sequence: + +```text +STOP ACCEPTING + ↓ +DRAIN REQUESTS + ↓ +DRAIN EVENTS + ↓ +CLOSE CONNECTIONS + ↓ +LEAVE CLUSTER + ↓ +STOP NATIVE RUNTIME + ↓ +STOP PYTHON RUNTIME +``` + +The native runtime must provide deterministic shutdown. + +--- + +# 33. Version Compatibility + +Cluster nodes MUST negotiate protocol compatibility. + +```text +Node A +protocol: 3.x + │ + ▼ +Node B +protocol: 3.x +``` + +Incompatible nodes MUST be rejected explicitly. + +The application/plugin version and transport protocol version MUST remain separate. + +--- + +# 34. Proposed Repository Structure + +```text +xcore/ +├── kernel/ +├── services/ +├── sdk/ +└── ... + +native/ +└── xcore-runtime/ + ├── Cargo.toml + ├── src/ + │ ├── core/ + │ ├── ipc/ + │ ├── transport/ + │ ├── router/ + │ ├── federation/ + │ ├── cluster/ + │ ├── events/ + │ ├── resilience/ + │ ├── discovery/ + │ ├── serialization/ + │ ├── telemetry/ + │ └── bindings/ + └── tests/ +``` + +Python remains the main XCore repository layer. + +Rust is introduced as a native subsystem rather than replacing the Python package. + +--- + +# 35. Migration Strategy + +V3 MUST be incremental. + +### Phase 1 — Native Core + +Implement: + +* Rust runtime; +* Python bindings; +* native IPC abstraction; +* serialization; +* benchmark infrastructure. + +### Phase 2 — Local IPC + +Move local high-frequency IPC paths to Rust. + +Existing Python IPC remains available as compatibility fallback. + +### Phase 3 — Transport + +Implement: + +* connection manager; +* node identity; +* transport; +* remote calls. + +### Phase 4 — Federation + +Implement: + +* node registry; +* static federation; +* routing; +* FederatedHandler. + +### Phase 5 — Distributed Events + +Move XBus transport to the native layer while preserving the Python event API. + +### Phase 6 — Resilience + +Implement: + +* circuit breaker; +* retry; +* failover; +* backpressure; +* health monitoring. + +### Phase 7 — Production Hardening + +Add: + +* security; +* benchmarks; +* chaos testing; +* observability; +* protocol compatibility tests; +* load testing. + +--- + +# 36. Compatibility Fallback + +V3 MUST maintain a fallback path. + +```text +Native Runtime available? + │ + ┌───┴───┐ + │ │ + YES NO + │ │ + Rust Python + Runtime fallback +``` + +This is important during migration. + +The Python implementation remains the reference compatibility implementation until the native implementation reaches production maturity. + +--- + +# 37. Testing Strategy + +V3 requires several test layers. + +## Unit Tests + +Rust: + +* router; +* transport; +* serialization; +* circuit breaker; +* retry; +* membership; +* queues. + +Python: + +* bindings; +* API compatibility; +* plugin behavior. + +## Integration Tests + +```text +Python ↔ Rust +Python ↔ Node A +Node A ↔ Node B +Node A ↔ Node B ↔ Node C +``` + +## Failure Tests + +Simulate: + +* node crash; +* network partition; +* timeout; +* connection reset; +* plugin crash; +* event consumer crash; +* overloaded queue. + +## Chaos Tests + +The cluster must be tested under controlled failures before V3 is considered production-ready. + +--- + +# 38. Definition of Done + +V3 is considered complete when: + +* [ ] Rust Native Runtime exists. +* [ ] Python bindings are stable. +* [ ] Local IPC can use the native runtime. +* [ ] Remote IPC works. +* [ ] Static federation works. +* [ ] Inter-node routing works. +* [ ] Cluster IPC works. +* [ ] Distributed XBus works. +* [ ] Tenant context propagates across nodes. +* [ ] Circuit breaker works. +* [ ] Failover works. +* [ ] Backpressure works. +* [ ] Distributed tracing works. +* [ ] Node authentication works. +* [ ] Failure scenarios are covered by integration tests. +* [ ] Performance benchmarks are established. +* [ ] Existing Python plugins remain compatible. + +--- + +# 39. Architectural Target + +The final V3 architecture is: + +```text + XCORE V3 + │ + ┌──────────────┴──────────────┐ + │ │ + PYTHON CONTROL PLANE RUST NATIVE PLANE + │ │ + ┌───────┼────────┐ ┌─────────┼─────────┐ + │ │ │ │ │ │ + Plugins API Services IPC Events Router + │ │ │ │ │ │ + └───────┴────────┘ │ │ │ + │ └─────────┼─────────┘ + │ │ + └─────────────┬───────────────┘ + │ + XCore Node + │ + ┌──────────────┼──────────────┐ + │ │ │ + Node A Node B Node C + │ │ │ + └──────────────┼──────────────┘ + │ + Cluster +``` + +The key architectural invariant is: + +> **Python remains the programmable surface of XCore. Rust becomes the execution fabric of XCore.** + +This allows XCore to retain the flexibility of Python while gaining a native distributed runtime capable of handling the performance, concurrency and resilience requirements introduced by V3. diff --git a/roadmap/executed_roadmap.md b/roadmap/executed_roadmap.md index 2e1204a..69119e7 100644 --- a/roadmap/executed_roadmap.md +++ b/roadmap/executed_roadmap.md @@ -57,6 +57,8 @@ Ce document présente l'état actuel du framework XCore par rapport aux objectif ## 🌐 V3 — Distribution **Objectif : Sortir du mono-processus.** +**Spécification technique (28/09/2026)** : `roadmap/V3_NATIVE_RUNTIME_SPEC.md` — Python reste le plan de contrôle (plugins, SDK, API, orchestration) ; un nouveau runtime natif Rust (`xcore-runtime`, embarqué via PyO3) prend en charge l'appartenance au cluster, le transport inter-nœuds, le routage, le volet distribué de XBus, le circuit breaker et le failover. Chaque ligne du tableau ci-dessous correspond à une section de cette spec — rien n'est encore implémenté, c'est l'architecture cible pour une fois la fenêtre de maintenance V2 terminée. + | Fonctionnalité | État | Localisation / Note | | :--- | :---: | :--- | | Federation statique | ❌ | Non implémenté | From f8a225241497c6160d2ae60ca071469054ff707a Mon Sep 17 00:00:00 2001 From: traoreera Date: Wed, 30 Sep 2026 12:07:22 +0000 Subject: [PATCH 06/15] =?UTF-8?q?fix(sandbox):=20logs=20des=20plugins=20sa?= =?UTF-8?q?ndboxed=20invisibles=20(LOG=5FLEVEL=20fig=C3=A9=20+=20stderr=20?= =?UTF-8?q?non=20drain=C3=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux bugs corrigés dans xcore/kernel/sandbox/ : le subprocess sandbox tournait toujours avec LOG_LEVEL=WARNING (jamais transmis depuis integration.yaml), et son stderr n'était lu qu'au crash au lieu d'être drainé en continu. Comme sandboxed est le mode par défaut depuis 2.6.2, ça touchait tout plugin sans execution_mode déclaré. KernelContext.log_level (nouveau champ, peuplé depuis observability.logging.level au boot) est maintenant propagé jusqu'à l'env du subprocess via SandboxedActivator, et une tâche _stderr_pump() relaie stderr en continu vers le logger du process principal. Bump 2.6.2 -> 2.6.3. --- CHANGELOG.md | 6 ++++ doc/changelog.md | 6 ++++ pyproject.toml | 2 +- xcore/__init__.py | 1 + xcore/__version__.py | 4 +-- xcore/kernel/context.py | 1 + xcore/kernel/runtime/activator.py | 3 +- xcore/kernel/sandbox/process_manager.py | 47 +++++++++++++++++-------- 8 files changed, 52 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ff4225..a25974d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.6.3] - 2026-09-30 + +### Fixed +- **Sandboxed plugin logs never reached the console or `log/app.log`**: `xcore/kernel/sandbox/worker.py` hardcoded the subprocess `LOG_LEVEL` to `WARNING` when the env var wasn't set, and `SandboxProcessManager._spawn()` never set it — so a sandboxed plugin's `self.logger.info(...)` calls were dropped at the source regardless of `integration.yaml`'s `observability.logging.level`. Separately, `_watch_loop()` only ever read subprocess stderr once, in the crash path — so even `WARNING`/`ERROR`-level output from a healthy running plugin sat in the OS pipe buffer and was never drained during normal operation. Since `sandboxed` is now the default execution mode (2.6.2), this affected any plugin that doesn't explicitly declare `execution_mode`. + Fixed by threading the real configured level through a new `KernelContext.log_level` field (set from `observability.logging.level` at boot) → `SandboxedActivator` → the subprocess `env` in `SandboxProcessManager._spawn()`, and by replacing the one-shot crash-time stderr read with a `_stderr_pump()` task that drains stderr continuously for the subprocess's whole lifetime and relays each line through the main process's logger (`xcore/kernel/sandbox/process_manager.py`). + ## [2.6.2] - 2026-09-28 ### Security diff --git a/doc/changelog.md b/doc/changelog.md index 4ff4225..a25974d 100644 --- a/doc/changelog.md +++ b/doc/changelog.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.6.3] - 2026-09-30 + +### Fixed +- **Sandboxed plugin logs never reached the console or `log/app.log`**: `xcore/kernel/sandbox/worker.py` hardcoded the subprocess `LOG_LEVEL` to `WARNING` when the env var wasn't set, and `SandboxProcessManager._spawn()` never set it — so a sandboxed plugin's `self.logger.info(...)` calls were dropped at the source regardless of `integration.yaml`'s `observability.logging.level`. Separately, `_watch_loop()` only ever read subprocess stderr once, in the crash path — so even `WARNING`/`ERROR`-level output from a healthy running plugin sat in the OS pipe buffer and was never drained during normal operation. Since `sandboxed` is now the default execution mode (2.6.2), this affected any plugin that doesn't explicitly declare `execution_mode`. + Fixed by threading the real configured level through a new `KernelContext.log_level` field (set from `observability.logging.level` at boot) → `SandboxedActivator` → the subprocess `env` in `SandboxProcessManager._spawn()`, and by replacing the one-shot crash-time stderr read with a `_stderr_pump()` task that drains stderr continuously for the subprocess's whole lifetime and relays each line through the main process's logger (`xcore/kernel/sandbox/process_manager.py`). + ## [2.6.2] - 2026-09-28 ### Security diff --git a/pyproject.toml b/pyproject.toml index 4b0593b..4fa7b9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "XCoreRuntime" -version = "2.6.2" +version = "2.6.3" description = "Plugin-first orchestration framework built on FastAPI" authors = [ { name = "Eliezer Traore", email = "68350805+traoreera@users.noreply.github.com" }, diff --git a/xcore/__init__.py b/xcore/__init__.py index 5d1589c..d9ed44e 100644 --- a/xcore/__init__.py +++ b/xcore/__init__.py @@ -233,6 +233,7 @@ async def _check(_s=_svc): metrics=self.metrics, tracer=self.tracer, health=self.health, + log_level=self._config.observability.logging.level.upper(), ) self.plugins = PluginSupervisor(ctx) diff --git a/xcore/__version__.py b/xcore/__version__.py index 94d7bcf..4f12822 100644 --- a/xcore/__version__.py +++ b/xcore/__version__.py @@ -1,4 +1,4 @@ -__version__ = "2.6.2" -__version_info__ = (2, 6, 2) +__version__ = "2.6.3" +__version_info__ = (2, 6, 3) __author__ = "xcore contributors" __license__ = "MIT" diff --git a/xcore/kernel/context.py b/xcore/kernel/context.py index 7a9cfa9..ffde7be 100644 --- a/xcore/kernel/context.py +++ b/xcore/kernel/context.py @@ -33,6 +33,7 @@ class KernelContext: metrics: MetricsRegistry | None = None tracer: Tracer | None = None health: HealthChecker | None = None + log_level: str = "WARNING" def as_plugin_context_params( self, plugin_name: str, caller: Any = None diff --git a/xcore/kernel/runtime/activator.py b/xcore/kernel/runtime/activator.py index 160df1d..7d48a0a 100644 --- a/xcore/kernel/runtime/activator.py +++ b/xcore/kernel/runtime/activator.py @@ -91,7 +91,8 @@ async def activate(self, manifest: Any, loader: "PluginLoader") -> "PluginHandle if not scan.passed: raise ValueError("scan failed") - mgr = SandboxProcessManager(manifest=manifest, ctx=loader) + log_level = getattr(loader._ctx, "log_level", "WARNING") + mgr = SandboxProcessManager(manifest=manifest, ctx=loader, log_level=log_level) await mgr.start() return mgr diff --git a/xcore/kernel/sandbox/process_manager.py b/xcore/kernel/sandbox/process_manager.py index a06ba9c..4700cee 100644 --- a/xcore/kernel/sandbox/process_manager.py +++ b/xcore/kernel/sandbox/process_manager.py @@ -54,6 +54,7 @@ def __init__( manifest, ctx: "PluginLoader", config: SandboxConfig | None = None, + log_level: str = "WARNING", ) -> None: self.manifest = manifest self.config = config or SandboxConfig() @@ -64,6 +65,8 @@ def __init__( self._started_at: float | None = None self._watch_task: asyncio.Task | None = None self._health_task: asyncio.Task | None = None + self._stderr_task: asyncio.Task | None = None + self._log_level = log_level data_dir = manifest.plugin_dir / "data" self._ctx = ctx @@ -95,6 +98,9 @@ async def start(self) -> None: self._watch_task = asyncio.create_task( self._watch_loop(), name=f"watch-{self.manifest.name}" ) + self._stderr_task = asyncio.create_task( + self._stderr_pump(), name=f"stderr-{self.manifest.name}" + ) hc = self.manifest.runtime.health_check if hc.enabled: self._health_task = asyncio.create_task( @@ -135,6 +141,7 @@ async def _spawn(self) -> None: "PYTHONUNBUFFERED": "1", "_SANDBOX_MAX_MEM_MB": str(self.manifest.resources.max_memory_mb), "_SANDBOX_MAX_CPU_SEC": "10", + "LOG_LEVEL": self._log_level, } env |= self.manifest.env @@ -194,19 +201,28 @@ async def _watch_loop(self) -> None: if self._state == ProcessState.STOPPED: return logger.warning("subprocess exited", plugin=self.manifest.name, exit_code=code) - if self._process.stderr: - with contextlib.suppress(Exception): - err = await asyncio.wait_for( - self._process.stderr.read(2048), timeout=1.0 - ) - if err: - logger.error( - "subprocess stderr output", - plugin=self.manifest.name, - stderr=err.decode("utf-8", "replace").strip(), - ) await self._handle_crash() + async def _stderr_pump(self) -> None: + """ + Draine stderr du subprocess en continu pendant toute sa durée de vie. + Sans ça, les logs WARNING+ du plugin (niveau lu depuis le pipe) restaient + bloqués dans le buffer OS et ne remontaient qu'au crash, en tronqué. + """ + if not self._process or not self._process.stderr: + return + stream = self._process.stderr + with contextlib.suppress(Exception): + while True: + line = await stream.readline() + if not line: + return + text = line.decode("utf-8", "replace").rstrip() + if text: + logger.warning( + "subprocess stderr", plugin=self.manifest.name, line=text + ) + async def _health_loop(self, interval: float, timeout: float) -> None: await asyncio.sleep(interval) while self._state == ProcessState.RUNNING: @@ -251,12 +267,12 @@ async def _handle_crash(self) -> None: ) await asyncio.sleep(delay) - for task in (self._watch_task, self._health_task): + for task in (self._watch_task, self._health_task, self._stderr_task): if task and not task.done(): task.cancel() with contextlib.suppress(asyncio.CancelledError): await task - self._watch_task = self._health_task = None + self._watch_task = self._health_task = self._stderr_task = None await self._kill() try: @@ -271,6 +287,9 @@ async def _handle_crash(self) -> None: self._watch_task = asyncio.create_task( self._watch_loop(), name=f"watch-{self.manifest.name}" ) + self._stderr_task = asyncio.create_task( + self._stderr_pump(), name=f"stderr-{self.manifest.name}" + ) hc = self.manifest.runtime.health_check if hc.enabled: self._health_task = asyncio.create_task( @@ -293,7 +312,7 @@ async def _handle_crash(self) -> None: async def stop(self) -> None: self._state = ProcessState.STOPPED - for task in (self._watch_task, self._health_task): + for task in (self._watch_task, self._health_task, self._stderr_task): if task and not task.done(): task.cancel() if self._channel: From 9bab559fd292c151a704e3e4d511e28a8f7440a0 Mon Sep 17 00:00:00 2001 From: traoreera Date: Wed, 30 Sep 2026 13:10:09 +0000 Subject: [PATCH 07/15] =?UTF-8?q?fix(sandbox):=20=5Fstderr=5Fpump()=20plus?= =?UTF-8?q?=20robuste=20+=20niveaux=20de=20log=20respect=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le code-review sur le commit précédent (b20a0eb) a trouvé 2 bugs dans _stderr_pump() : - un contextlib.suppress(Exception) global autour de toute la boucle tuait la pompe pour de bon au premier incident (ex: ligne stderr > 64 KiB -> ValueError) au lieu de gérer ça par itération. - toutes les lignes stderr relayées passaient par logger.warning() sans distinction, ce qui contredit l'objectif du fix précédent (respecter LOG_LEVEL) : une plugin en DEBUG inonderait les logs de WARNING. Fix : gestion d'erreur par itération (ValueError sur ligne trop longue loggée et la boucle continue ; autre exception loggée puis arrêt propre de la pompe), et parsing du bracket [LEVEL] déjà présent dans le format de worker.py pour router chaque ligne vers le bon niveau (fallback en error pour une ligne non structurée, ex: traceback brut). Ajoute un test qui exerce réellement _stderr_pump() (l'ancien mock du stderr dans test_manager_start_success ne passait jamais dans le code ajouté) et un test de non-régression sur le format de worker.py. --- tests/unit/kernel/test_process_manager.py | 137 +++++++++++++++++++--- xcore/kernel/sandbox/process_manager.py | 62 ++++++++-- 2 files changed, 177 insertions(+), 22 deletions(-) diff --git a/tests/unit/kernel/test_process_manager.py b/tests/unit/kernel/test_process_manager.py index 66ed14c..9eeaea2 100644 --- a/tests/unit/kernel/test_process_manager.py +++ b/tests/unit/kernel/test_process_manager.py @@ -3,11 +3,37 @@ """ import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + import pytest -from unittest.mock import MagicMock, AsyncMock, patch -from pathlib import Path -from xcore.kernel.sandbox.process_manager import SandboxProcessManager, SandboxConfig, ProcessState -from xcore.kernel.sandbox.ipc import IPCResponse, IPCProcessDead, IPCTimeoutError + +from xcore.kernel.sandbox.ipc import IPCProcessDead, IPCResponse, IPCTimeoutError +from xcore.kernel.sandbox.process_manager import ( + _STDERR_LEVEL_RE, + ProcessState, + SandboxConfig, + SandboxProcessManager, +) + + +class _FakeStderr: + """ + Simule asyncio.StreamReader.readline() pour tester _stderr_pump() sans + subprocess réel : items est une liste de bytes (une "ligne" chacun, + la dernière b"" simule l'EOF) ou d'exceptions à lever à cette itération. + """ + + def __init__(self, items): + self._items = list(items) + + async def readline(self): + if not self._items: + return b"" + item = self._items.pop(0) + if isinstance(item, Exception): + raise item + return item + @pytest.fixture def mock_manifest(tmp_path): @@ -23,21 +49,20 @@ def mock_manifest(tmp_path): manifest.runtime.health_check.timeout_seconds = 0.1 return manifest + @pytest.fixture def mock_loader(): loader = MagicMock() loader._events.emit_sync = MagicMock() return loader + @pytest.fixture def manager(mock_manifest, mock_loader): - config = SandboxConfig( - startup_timeout=0.1, - restart_delay=0.01, - max_restarts=2 - ) + config = SandboxConfig(startup_timeout=0.1, restart_delay=0.01, max_restarts=2) return SandboxProcessManager(mock_manifest, mock_loader, config=config) + @pytest.mark.asyncio async def test_manager_init(manager, mock_manifest): assert manager.state == ProcessState.STOPPED @@ -45,6 +70,7 @@ async def test_manager_init(manager, mock_manifest): assert manager.uptime is None assert manager.status()["name"] == "test_plugin" + @pytest.mark.asyncio async def test_manager_start_success(manager, mock_manifest, mock_loader): with patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_spawn: @@ -53,11 +79,13 @@ async def test_manager_start_success(manager, mock_manifest, mock_loader): mock_proc.returncode = None mock_proc.stdin = MagicMock() mock_proc.stdout = MagicMock() - mock_proc.stderr = MagicMock() + mock_proc.stderr = _FakeStderr([b""]) # EOF immédiat, pump se termine seule mock_proc.wait = AsyncMock(return_value=0) mock_spawn.return_value = mock_proc - with patch("xcore.kernel.sandbox.ipc.IPCChannel.call", new_callable=AsyncMock) as mock_call: + with patch( + "xcore.kernel.sandbox.ipc.IPCChannel.call", new_callable=AsyncMock + ) as mock_call: mock_call.return_value = IPCResponse(success=True, data={"status": "ok"}) await manager.start() @@ -68,6 +96,7 @@ async def test_manager_start_success(manager, mock_manifest, mock_loader): mock_spawn.assert_called_once() mock_loader._events.emit_sync.assert_called() + @pytest.mark.asyncio async def test_manager_start_timeout(manager, mock_manifest): with patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_spawn: @@ -77,7 +106,9 @@ async def test_manager_start_timeout(manager, mock_manifest): mock_proc.wait = AsyncMock(return_value=1) mock_spawn.return_value = mock_proc - with patch("xcore.kernel.sandbox.ipc.IPCChannel.call", new_callable=AsyncMock) as mock_call: + with patch( + "xcore.kernel.sandbox.ipc.IPCChannel.call", new_callable=AsyncMock + ) as mock_call: mock_call.side_effect = asyncio.TimeoutError() with pytest.raises(RuntimeError, match="Pas de réponse au ping"): @@ -86,12 +117,15 @@ async def test_manager_start_timeout(manager, mock_manifest): assert manager.state == ProcessState.STARTING mock_proc.terminate.assert_called() + @pytest.mark.asyncio async def test_manager_call_success(manager): # Setup running state manager._state = ProcessState.RUNNING manager._channel = MagicMock() - manager._channel.call = AsyncMock(return_value=IPCResponse(success=True, data={"status": "ok", "result": "hi"})) + manager._channel.call = AsyncMock( + return_value=IPCResponse(success=True, data={"status": "ok", "result": "hi"}) + ) # Execute res = await manager.call("hello", {"name": "world"}) @@ -100,6 +134,7 @@ async def test_manager_call_success(manager): assert res == {"status": "ok", "result": "hi"} manager._channel.call.assert_called_with("hello", {"name": "world"}) + @pytest.mark.asyncio async def test_manager_call_not_available(manager): with pytest.raises(RuntimeError, match="non disponible"): @@ -135,6 +170,7 @@ async def test_manager_call_ipc_timeout_triggers_recycle(manager): await manager.call("ping", {}) mock_crash.assert_called_once() + @pytest.mark.asyncio async def test_manager_stop(manager): manager._state = ProcessState.RUNNING @@ -150,6 +186,7 @@ async def test_manager_stop(manager): manager._channel.close.assert_called_once() manager._process.terminate.assert_called() + @pytest.mark.asyncio async def test_manager_handle_crash_restart_success(manager, mock_manifest): manager._state = ProcessState.RUNNING @@ -166,11 +203,12 @@ async def test_manager_handle_crash_restart_success(manager, mock_manifest): assert manager._restarts == 1 mock_spawn.assert_called_once() + @pytest.mark.asyncio async def test_manager_handle_crash_max_restarts(manager, mock_manifest): manager._state = ProcessState.RUNNING manager.config.max_restarts = 1 - manager._restarts = 0 # Start from 0 to allow one loop + manager._restarts = 0 # Start from 0 to allow one loop with patch.object(manager, "_spawn", new_callable=AsyncMock) as mock_spawn: mock_spawn.side_effect = Exception("Spawn failed") @@ -179,3 +217,74 @@ async def test_manager_handle_crash_max_restarts(manager, mock_manifest): assert manager.state == ProcessState.FAILED assert manager._restarts == 1 + + +@pytest.mark.asyncio +async def test_stderr_pump_dispatches_by_level_and_survives_bad_line(manager): + """ + _stderr_pump() doit : router chaque ligne vers le niveau logger indiqué + par son bracket [LEVEL] (format worker.py), retomber en error() pour une + ligne non structurée (traceback brut), et continuer à lire après une + ValueError (ligne trop longue) au lieu de s'arrêter définitivement. + """ + manager._process = MagicMock() + manager._process.stderr = _FakeStderr( + [ + b"2026-09-30 12:00:00,000 [INFO] worker: plugin loaded\n", + b"2026-09-30 12:00:00,001 [ERROR] worker: boom\n", + b"raw traceback line without brackets\n", + ValueError("Separator is not found, and chunk exceed the limit"), + b"2026-09-30 12:00:00,002 [DEBUG] worker: still alive after bad line\n", + b"", + ] + ) + + with patch("xcore.kernel.sandbox.process_manager.logger") as mock_logger: + await manager._stderr_pump() + + mock_logger.info.assert_any_call( + "subprocess stderr", + plugin="test_plugin", + line="2026-09-30 12:00:00,000 [INFO] worker: plugin loaded", + ) + mock_logger.error.assert_any_call( + "subprocess stderr", + plugin="test_plugin", + line="2026-09-30 12:00:00,001 [ERROR] worker: boom", + ) + # Ligne non structurée -> error par défaut (pas warning), comme + # l'ancien lecteur crash-time. + mock_logger.error.assert_any_call( + "subprocess stderr", + plugin="test_plugin", + line="raw traceback line without brackets", + ) + # La ValueError est catchée et loggée à part, sans arrêter la pompe. + assert mock_logger.warning.called + # La ligne suivant la ValueError est bien lue -> pas de mort silencieuse. + mock_logger.debug.assert_any_call( + "subprocess stderr", + plugin="test_plugin", + line="2026-09-30 12:00:00,002 [DEBUG] worker: still alive after bad line", + ) + + +def test_worker_stderr_format_stays_compatible_with_level_regex(): + """ + _STDERR_LEVEL_RE parse le format de logging.basicConfig de worker.py pour + router chaque ligne stderr vers le bon niveau. Si ce format change un + jour, ce test doit casser bruyamment plutôt que de laisser tout le + sandbox logging retomber silencieusement sur le niveau par défaut. + """ + import inspect + + from xcore.kernel.sandbox import worker + + source = inspect.getsource(worker) + assert "[%(levelname)s]" in source, ( + "worker.py logging format changed — update " + "process_manager._STDERR_LEVEL_RE (or restore the bracketed " + "levelname) to keep stderr level dispatch working" + ) + rendered = "2026-01-01 00:00:00 [WARNING] worker: test" + assert _STDERR_LEVEL_RE.search(rendered) diff --git a/xcore/kernel/sandbox/process_manager.py b/xcore/kernel/sandbox/process_manager.py index 4700cee..f8a16ae 100644 --- a/xcore/kernel/sandbox/process_manager.py +++ b/xcore/kernel/sandbox/process_manager.py @@ -7,6 +7,7 @@ import asyncio import contextlib +import re import sys import time from dataclasses import dataclass @@ -24,6 +25,18 @@ logger = get_logger("xcore.sandbox.process_manager") +# Le worker sandbox formate ses lignes stderr via logging.basicConfig avec +# "%(levelname)s" entre crochets (voir worker.py) — on s'en sert pour relayer +# chaque ligne au bon niveau plutôt que de tout logger en warning. +_STDERR_LEVEL_RE = re.compile(r"\[(DEBUG|INFO|WARNING|ERROR|CRITICAL)\]") +_STDERR_LEVEL_METHODS = { + "DEBUG": "debug", + "INFO": "info", + "WARNING": "warning", + "ERROR": "error", + "CRITICAL": "critical", +} + class ProcessState(Enum): STOPPED = "stopped" @@ -212,16 +225,49 @@ async def _stderr_pump(self) -> None: if not self._process or not self._process.stderr: return stream = self._process.stderr - with contextlib.suppress(Exception): - while True: + while True: + try: line = await stream.readline() - if not line: - return + except ValueError as e: + # Ligne plus longue que la limite du StreamReader (défaut 64 KiB) : + # readline() nettoie déjà le buffer interne avant de relever cette + # ValueError — sans ce catch dédié, le blanket suppress() d'avant + # tuait la tâche entière pour le reste de la vie du process. + logger.warning( + "subprocess stderr line too long, skipped", + plugin=self.manifest.name, + error=str(e), + ) + continue + except Exception as e: + logger.error( + "subprocess stderr pump stopped unexpectedly", + plugin=self.manifest.name, + error=str(e), + ) + return + if not line: + return + try: text = line.decode("utf-8", "replace").rstrip() - if text: - logger.warning( - "subprocess stderr", plugin=self.manifest.name, line=text - ) + if not text: + continue + # Ligne sans bracket [LEVEL] reconnu (traceback brut, print, ou + # "FATAL: ..." émis directement sur stderr par worker.py) : par + # défaut en error, comme le faisait l'ancien lecteur au crash — + # une ligne non structurée est plus probablement un problème + # qu'un warning bénin. + match = _STDERR_LEVEL_RE.search(text) + method_name = _STDERR_LEVEL_METHODS.get( + match.group(1) if match else "", "error" + ) + getattr(logger, method_name)( + "subprocess stderr", plugin=self.manifest.name, line=text + ) + except Exception: + # Un échec du côté décodage/log (jamais vu en pratique) ne doit + # pas arrêter le drainage des lignes suivantes. + continue async def _health_loop(self, interval: float, timeout: float) -> None: await asyncio.sleep(interval) From 3c9a1e16f3ddc2e9c493e63445f64798146f84d9 Mon Sep 17 00:00:00 2001 From: traoreera Date: Wed, 30 Sep 2026 13:33:03 +0000 Subject: [PATCH 08/15] =?UTF-8?q?fix(sandbox):=203=20bugs=20suppl=C3=A9men?= =?UTF-8?q?taires=20trouv=C3=A9s=20en=20continuant=20l'audit=20du=20worker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suite de code-review sur worker.py (jusque là non touché par ce branch) : - FilesystemGuard n'avait jamais patché os.symlink/os.link ni Path.symlink_to/hardlink_to — un plugin sandboxed pouvait créer un lien vers n'importe quel chemin hors sandbox sans passer par is_allowed(). Ajoutés aux deux listes d'opérations gardées. - _guarded_op() n'extrayait les chemins que des arguments explicitement passés (sig.bind_partial().arguments) : un appel s'appuyant sur une valeur par défaut (ex: os.listdir() -> cwd) avait un bound.arguments vide et passait sans aucune vérification. Fix : apply_defaults() avant d'extraire les chemins. - reader.readline() sur le pipe IPC stdin pouvait lever une ValueError (ligne > 64 KiB, même limite StreamReader que le bug stderr de b20a0eb/c2a3735) non catchée, tuant tout le worker sur un seul message trop gros au lieu de juste l'ignorer. Fix connexe trouvé en même temps : logging.basicConfig(format="...") dans worker.py ignorait silencieusement les champs structurés (logger.info(msg, plugin=..., error=...), passés via extra={"xcore_ctx": ...}) — un format texte brut ne les rend pas. Même après le fix LOG_LEVEL/stderr-pump de ce branch, ces champs restaient invisibles. Remplacé par _TextFormatter (même formateur que le process principal). _STDERR_LEVEL_RE (process_manager.py) ajusté en conséquence (le levelname de _TextFormatter est aligné sur 8 caractères, espaces possibles avant le "]"). Renommage cosmétique : _extracted_from__load_manifest_20 -> _parse_manifest_file (résidu d'un outil de refactor automatique dans un module sécurité). Pas de nouveau test pour symlink/apply_defaults : FilesystemGuard.install() mute os/builtins au niveau du module Python entier, et le test existant couvrant ce chemin est déjà explicitement skip ("Guard installation modifies global state", test_sandbox_worker.py:137) — vérifié par lecture directe des signatures os.symlink/os.link/Path.symlink_to/ hardlink_to plutôt que par un nouveau test risquant de muter l'état global du process de test. --- tests/unit/kernel/test_process_manager.py | 62 ++++++++++++++++++----- xcore/kernel/sandbox/process_manager.py | 10 ++-- xcore/kernel/sandbox/worker.py | 37 +++++++++++--- 3 files changed, 87 insertions(+), 22 deletions(-) diff --git a/tests/unit/kernel/test_process_manager.py b/tests/unit/kernel/test_process_manager.py index 9eeaea2..e5d0e6f 100644 --- a/tests/unit/kernel/test_process_manager.py +++ b/tests/unit/kernel/test_process_manager.py @@ -271,20 +271,58 @@ async def test_stderr_pump_dispatches_by_level_and_survives_bad_line(manager): def test_worker_stderr_format_stays_compatible_with_level_regex(): """ - _STDERR_LEVEL_RE parse le format de logging.basicConfig de worker.py pour - router chaque ligne stderr vers le bon niveau. Si ce format change un - jour, ce test doit casser bruyamment plutôt que de laisser tout le - sandbox logging retomber silencieusement sur le niveau par défaut. + _STDERR_LEVEL_RE parse la sortie du _TextFormatter utilisé par worker.py + (xcore/kernel/observability/logging.py, même formateur que le process + principal) pour router chaque ligne stderr vers le bon niveau. Rend un + vrai LogRecord à travers le vrai formateur plutôt que de comparer des + chaînes, pour attraper toute dérive future de leur formatage respectif. """ - import inspect + import logging + + from xcore.kernel.observability.logging import _TextFormatter + + formatter = _TextFormatter() + for level_name, level_no in ( + ("DEBUG", logging.DEBUG), + ("WARNING", logging.WARNING), + ("ERROR", logging.ERROR), + ): + record = logging.LogRecord( + name="xcore.worker", + level=level_no, + pathname=__file__, + lineno=1, + msg="test message", + args=(), + exc_info=None, + ) + rendered = formatter.format(record) + match = _STDERR_LEVEL_RE.search(rendered) + assert match, f"level regex didn't match rendered line: {rendered!r}" + assert match.group(1) == level_name + +def test_worker_formatter_renders_structured_fields(): + """ + worker.py doit utiliser _TextFormatter (pas un format="..." brut) pour + que les champs structurés (logger.info(msg, plugin=..., error=...)) + apparaissent dans stderr — sinon ils sont attachés via extra={"xcore_ctx"} + et un Formatter texte simple les ignore silencieusement. + """ from xcore.kernel.sandbox import worker - source = inspect.getsource(worker) - assert "[%(levelname)s]" in source, ( - "worker.py logging format changed — update " - "process_manager._STDERR_LEVEL_RE (or restore the bracketed " - "levelname) to keep stderr level dispatch working" + assert isinstance(worker._worker_handler.formatter, type(worker._TextFormatter())) + + record = worker.logging.LogRecord( + name="xcore.worker", + level=worker.logging.ERROR, + pathname=__file__, + lineno=1, + msg="sandbox filesystem violation", + args=(), + exc_info=None, ) - rendered = "2026-01-01 00:00:00 [WARNING] worker: test" - assert _STDERR_LEVEL_RE.search(rendered) + record.xcore_ctx = {"plugin": "test_plugin", "error": "boom"} + rendered = worker._worker_handler.formatter.format(record) + assert "plugin=test_plugin" in rendered + assert "error=boom" in rendered diff --git a/xcore/kernel/sandbox/process_manager.py b/xcore/kernel/sandbox/process_manager.py index f8a16ae..1e92b7c 100644 --- a/xcore/kernel/sandbox/process_manager.py +++ b/xcore/kernel/sandbox/process_manager.py @@ -25,10 +25,12 @@ logger = get_logger("xcore.sandbox.process_manager") -# Le worker sandbox formate ses lignes stderr via logging.basicConfig avec -# "%(levelname)s" entre crochets (voir worker.py) — on s'en sert pour relayer -# chaque ligne au bon niveau plutôt que de tout logger en warning. -_STDERR_LEVEL_RE = re.compile(r"\[(DEBUG|INFO|WARNING|ERROR|CRITICAL)\]") +# Le worker sandbox formate ses lignes stderr via _TextFormatter (même +# formateur que le process principal, xcore/kernel/observability/logging.py), +# qui aligne le levelname sur 8 caractères -> espaces possibles avant le "]" +# (ex: "[INFO ]"). On s'en sert pour relayer chaque ligne au bon niveau +# plutôt que de tout logger en warning. +_STDERR_LEVEL_RE = re.compile(r"\[(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s*\]") _STDERR_LEVEL_METHODS = { "DEBUG": "debug", "INFO": "info", diff --git a/xcore/kernel/sandbox/worker.py b/xcore/kernel/sandbox/worker.py index 81c70c3..96366a0 100644 --- a/xcore/kernel/sandbox/worker.py +++ b/xcore/kernel/sandbox/worker.py @@ -23,15 +23,23 @@ from pathlib import Path from xcore.kernel.observability import get_logger +from xcore.kernel.observability.logging import _TextFormatter # ContextVar par tâche asyncio — évite les race conditions entre coroutines # qui partageraient le même FilesystemGuard (requis pour la sécurité sandbox). _sandbox_in_guard: ContextVar[bool] = ContextVar("sandbox_in_guard", default=False) +# _TextFormatter (le même que le process principal, xcore/kernel/observability/ +# logging.py) et non un simple format="..." : un basicConfig(format=...) plein +# texte ignore les champs structurés passés en kwargs (logger.info(msg, plugin=...)) +# — ils sont attachés via extra={"xcore_ctx": ...} et seuls _TextFormatter/ +# _JsonFormatter savent les rendre. Sans ça, ces champs étaient silencieusement +# perdus même une fois le niveau et le drainage stderr corrigés côté kernel. +_worker_handler = logging.StreamHandler(sys.stderr) +_worker_handler.setFormatter(_TextFormatter()) logging.basicConfig( level=os.environ.get("LOG_LEVEL", "WARNING"), - format="%(asctime)s [%(levelname)s] worker: %(message)s", - stream=sys.stderr, + handlers=[_worker_handler], ) logger = get_logger("xcore.worker") @@ -292,10 +300,15 @@ def wrapper(*args, **kwargs): if sig is not None: try: - bound = sig.bind_partial(*args, **kwargs).arguments + bound = sig.bind_partial(*args, **kwargs) + # apply_defaults() : sans ça, un appel qui compte sur + # une valeur par défaut (ex: os.listdir() -> cwd) a un + # bound.arguments vide et passait le guard sans aucune + # vérification. + bound.apply_defaults() paths = [ v - for k, v in bound.items() + for k, v in bound.arguments.items() if k in pnames and isinstance(v, (str, os.PathLike)) ] except Exception: @@ -345,6 +358,8 @@ def __init__(self, file, *args, **kwargs): "stat", "lstat", "chmod", + "symlink", + "link", ]: if hasattr(os, op): setattr(os, op, _guarded_op(getattr(os, op), f"os.{op}")) @@ -363,6 +378,8 @@ def __init__(self, file, *args, **kwargs): "exists", "is_file", "is_dir", + "symlink_to", + "hardlink_to", ]: if hasattr(_Path, op): setattr(_Path, op, _guarded_op(getattr(_Path, op), f"Path.{op}")) @@ -703,7 +720,7 @@ def _load_manifest(plugin_dir: Path) -> _PluginManifest: if not manifest_path.exists(): continue try: - return _extracted_from__load_manifest_20(fname, manifest_path, manifest) + return _parse_manifest_file(fname, manifest_path, manifest) except Exception as e: logger.warning("cannot read manifest file", file=fname, error=str(e)) @@ -711,7 +728,7 @@ def _load_manifest(plugin_dir: Path) -> _PluginManifest: return manifest -def _extracted_from__load_manifest_20(fname, manifest_path, manifest: _PluginManifest): +def _parse_manifest_file(fname, manifest_path, manifest: _PluginManifest): if fname.endswith(".yaml"): import yaml @@ -821,6 +838,14 @@ def connection_lost(self, exc): line = await reader.readline() except (asyncio.IncompleteReadError, EOFError): break + except ValueError as e: + # Ligne IPC plus longue que la limite du StreamReader (défaut + # 64 KiB) : readline() nettoie déjà son buffer interne avant de + # relever cette ValueError. Sans ce catch, une seule requête trop + # grosse tuait tout le worker (et donc toutes les requêtes en + # cours/à venir pour ce plugin), pas seulement celle-là. + logger.warning("ipc line too long, skipped", error=str(e)) + continue if not line: break From 610f06ec302bb6ba970f5be6506a07d76e27badd Mon Sep 17 00:00:00 2001 From: traoreera Date: Thu, 1 Oct 2026 20:35:05 +0000 Subject: [PATCH 09/15] =?UTF-8?q?fix(lifecycle):=20reload=20apr=C3=A8s=20b?= =?UTF-8?q?oot=20=E2=80=94=20le=20container=20partag=C3=A9=20n'est=20plus?= =?UTF-8?q?=20empoisonn=C3=A9=20(v2.6.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Après le boot, le 2e reload/load de n'importe quel plugin échouait avec « Impossible d'écraser le service protégé 'scheduler' » et le plugin restait bloqué en FAILED (unload refusé) : propagate_services(is_reload=True) réécrivait dans le dict partagé du ServiceContainer le proxy de ramasse-miette du plugin à la place du vrai scheduler, et chaque reload empilait un proxy de plus. - LifecycleManager mémorise les services injectés et ne propage/enregistre que ce que le plugin exporte réellement ; l'écrasement d'un service noyau par un autre objet reste rejeté ; le déballage des proxies couvre tous les niveaux - FAILED accepte unload (nettoyage forcé) et reload (nouvel essai) - un load()/reload() raté libère de force ce que le plugin avait enregistré - tests de régression avec un vrai PluginRegistry + ServiceContainer --- CHANGELOG.md | 11 ++ doc/changelog.md | 11 ++ pyproject.toml | 2 +- tests/unit/kernel/test_lifecycle_reload.py | 204 +++++++++++++++++++++ tests/unit/kernel/test_state_machine.py | 20 +- xcore/__version__.py | 4 +- xcore/kernel/runtime/lifecycle.py | 84 +++++++-- xcore/kernel/runtime/state_machine.py | 11 +- 8 files changed, 329 insertions(+), 18 deletions(-) create mode 100644 tests/unit/kernel/test_lifecycle_reload.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a25974d..15ec97f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.6.4] - 2026-10-01 + +### Fixed +- **After boot, the second plugin reload/load of the whole process failed and left the plugin stuck in `FAILED`**: `LifecycleManager.propagate_services(is_reload=True)` ran `self._services.update(instance_services)`, but `self._services` *is* the `ServiceContainer`'s shared dict and `instance_services` is the plugin's `ctx.services` copy — which holds the plugin's own garbage-collection proxy (`_ScopedScheduler`) in place of the real scheduler (and tenant wrappers in place of `db`/`cache` when tenancy is on). The first reload therefore replaced the kernel's real `scheduler` in the shared container with that plugin's proxy; the next reload/load of *any* plugin then received a proxy-of-a-proxy, which the one-level `_real` unwrapping could not match against the kernel-protected service, raising `PermissionError: Impossible d'écraser le service protégé 'scheduler'`. The proxy chain also grew by one level per reload. Reproduced against a real `PluginRegistry` + `ServiceContainer` (the existing tests mocked the registry, so none of this was visible): `reload(A)` OK, then `reload(B)`, `load(C)` and `reload(A)` all failed. + `LifecycleManager` now snapshots the services it injected (`_injected_services`, taken after tenant/GC wrapping, before `on_load`) and `propagate_services()` ignores any entry that is still *exactly* that injected object — it is received, not exported — in the registry loop, in the shared-container update and in the no-registry fallback check. Only services the plugin actually exported are written back; an attempt to replace a core service with a *different* object is still rejected. The proxy unwrapping fallback now strips every proxy level instead of one. +- **A plugin in `FAILED` could never be unloaded or reloaded**: the state machine only allowed `reset` from `FAILED`, and nothing in the kernel ever calls `reset` — so `_do_unload()` never ran for a plugin that failed mid-reload and its jobs, subscriptions, tasks and `sys.modules` entries stayed registered forever. `FAILED` now also accepts `unload` (forced cleanup) and `reload` (retry). +- **A failed `load()`/`reload()` left everything the plugin had already registered in place**: scheduler jobs, event/hook subscriptions, health checks, spawned tasks and `sys.modules` entries of a half-initialized plugin were never released. Both paths now run the forced cleanup (without calling the plugin's own hooks, whose state is inconsistent) before raising `LoadError`. + +### Added +- `tests/unit/kernel/test_lifecycle_reload.py`: regression suite for reload/load *after* boot with a real registry and container (repeated reloads, cross-plugin reload, hot-load after a reload, exported services still propagated, core-service override still rejected, failed-load cleanup, unload/reload from `FAILED`). + ## [2.6.3] - 2026-09-30 ### Fixed diff --git a/doc/changelog.md b/doc/changelog.md index a25974d..15ec97f 100644 --- a/doc/changelog.md +++ b/doc/changelog.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.6.4] - 2026-10-01 + +### Fixed +- **After boot, the second plugin reload/load of the whole process failed and left the plugin stuck in `FAILED`**: `LifecycleManager.propagate_services(is_reload=True)` ran `self._services.update(instance_services)`, but `self._services` *is* the `ServiceContainer`'s shared dict and `instance_services` is the plugin's `ctx.services` copy — which holds the plugin's own garbage-collection proxy (`_ScopedScheduler`) in place of the real scheduler (and tenant wrappers in place of `db`/`cache` when tenancy is on). The first reload therefore replaced the kernel's real `scheduler` in the shared container with that plugin's proxy; the next reload/load of *any* plugin then received a proxy-of-a-proxy, which the one-level `_real` unwrapping could not match against the kernel-protected service, raising `PermissionError: Impossible d'écraser le service protégé 'scheduler'`. The proxy chain also grew by one level per reload. Reproduced against a real `PluginRegistry` + `ServiceContainer` (the existing tests mocked the registry, so none of this was visible): `reload(A)` OK, then `reload(B)`, `load(C)` and `reload(A)` all failed. + `LifecycleManager` now snapshots the services it injected (`_injected_services`, taken after tenant/GC wrapping, before `on_load`) and `propagate_services()` ignores any entry that is still *exactly* that injected object — it is received, not exported — in the registry loop, in the shared-container update and in the no-registry fallback check. Only services the plugin actually exported are written back; an attempt to replace a core service with a *different* object is still rejected. The proxy unwrapping fallback now strips every proxy level instead of one. +- **A plugin in `FAILED` could never be unloaded or reloaded**: the state machine only allowed `reset` from `FAILED`, and nothing in the kernel ever calls `reset` — so `_do_unload()` never ran for a plugin that failed mid-reload and its jobs, subscriptions, tasks and `sys.modules` entries stayed registered forever. `FAILED` now also accepts `unload` (forced cleanup) and `reload` (retry). +- **A failed `load()`/`reload()` left everything the plugin had already registered in place**: scheduler jobs, event/hook subscriptions, health checks, spawned tasks and `sys.modules` entries of a half-initialized plugin were never released. Both paths now run the forced cleanup (without calling the plugin's own hooks, whose state is inconsistent) before raising `LoadError`. + +### Added +- `tests/unit/kernel/test_lifecycle_reload.py`: regression suite for reload/load *after* boot with a real registry and container (repeated reloads, cross-plugin reload, hot-load after a reload, exported services still propagated, core-service override still rejected, failed-load cleanup, unload/reload from `FAILED`). + ## [2.6.3] - 2026-09-30 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 4fa7b9b..a143f9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "XCoreRuntime" -version = "2.6.3" +version = "2.6.4" description = "Plugin-first orchestration framework built on FastAPI" authors = [ { name = "Eliezer Traore", email = "68350805+traoreera@users.noreply.github.com" }, diff --git a/tests/unit/kernel/test_lifecycle_reload.py b/tests/unit/kernel/test_lifecycle_reload.py new file mode 100644 index 0000000..2a0cc90 --- /dev/null +++ b/tests/unit/kernel/test_lifecycle_reload.py @@ -0,0 +1,204 @@ +""" +Régression : reload / load de plugins Trusted APRÈS le boot, avec un vrai +PluginRegistry (services noyau protégés) et un vrai ServiceContainer. + +Avant le correctif, le 1er reload d'un plugin écrivait son proxy de +ramasse-miette dans le dict partagé du ServiceContainer à la place du vrai +scheduler ; le reload/load suivant de n'importe quel plugin échouait alors avec +« Impossible d'écraser le service protégé 'scheduler' » et le plugin restait +bloqué en FAILED (unload refusé). +""" + +from types import SimpleNamespace + +import pytest + +from xcore.configurations.sections import SchedulerConfig, ServicesConfig +from xcore.kernel.context import KernelContext +from xcore.kernel.events.bus import EventBus +from xcore.kernel.events.hooks import HookManager +from xcore.kernel.observability.health import HealthChecker +from xcore.kernel.runtime.lifecycle import LifecycleManager, LoadError +from xcore.kernel.runtime.state_machine import PluginState +from xcore.registry.index import PluginRegistry +from xcore.services.container import ServiceContainer +from xcore.services.scheduler import service as scheduler_module +from xcore.services.scheduler.service import SchedulerService + +PLUGIN_SRC = """ +from xcore.kernel.api.contract import TrustedBase + +class Plugin(TrustedBase): + async def handle(self, action, payload): + return {"status": "ok"} +""" + + +def _manifest(tmp_path, name): + plugin_dir = tmp_path / name + (plugin_dir / "src").mkdir(parents=True) + (plugin_dir / "src" / "main.py").write_text(PLUGIN_SRC) + return SimpleNamespace( + name=name, + plugin_dir=plugin_dir, + entry_point="src/main.py", + resources=SimpleNamespace(timeout_seconds=10), + env={}, + requires=[], + extra={}, + ) + + +@pytest.fixture +async def booted_kernel(): + """Noyau « après boot » : scheduler réel + services noyau protégés.""" + scheduler = SchedulerService(SchedulerConfig()) + await scheduler.init() + container = ServiceContainer(ServicesConfig()) + container._raw["scheduler"] = scheduler + registry = PluginRegistry() + ctx = KernelContext( + config=SimpleNamespace(tenancy=SimpleNamespace(enabled=False)), + services=container, + registry=registry, + events=EventBus(), + hooks=HookManager(), + health=HealthChecker(), + ) + yield SimpleNamespace( + ctx=ctx, container=container, registry=registry, scheduler=scheduler + ) + scheduler_module._JOB_REGISTRY.clear() + await scheduler.shutdown() + + +def _finish_boot(kernel): + """Reproduit PluginSupervisor.boot() étape 5 : register_core_service().""" + for name, svc in kernel.container.as_dict().items(): + kernel.registry.register_core_service(name, svc) + + +class TestReloadAfterBoot: + async def test_reload_twice_succeeds(self, booted_kernel, tmp_path): + lm = LifecycleManager(_manifest(tmp_path, "a"), booted_kernel.ctx) + await lm.load() + _finish_boot(booted_kernel) + + for _ in range(3): + await lm.reload() + assert lm.state == PluginState.READY + + async def test_reload_keeps_real_core_services_in_shared_container( + self, booted_kernel, tmp_path + ): + lm = LifecycleManager(_manifest(tmp_path, "a"), booted_kernel.ctx) + await lm.load() + _finish_boot(booted_kernel) + + for _ in range(3): + await lm.reload() + assert ( + booted_kernel.container.as_dict()["scheduler"] + is booted_kernel.scheduler + ) + + async def test_reload_of_one_plugin_does_not_break_the_others( + self, booted_kernel, tmp_path + ): + a = LifecycleManager(_manifest(tmp_path, "a"), booted_kernel.ctx) + b = LifecycleManager(_manifest(tmp_path, "b"), booted_kernel.ctx) + await a.load() + await b.load() + _finish_boot(booted_kernel) + + await a.reload() + await b.reload() + await a.reload() + + # hot-load d'un plugin neuf après des reloads + c = LifecycleManager(_manifest(tmp_path, "c"), booted_kernel.ctx) + await c.load() + + assert [m.state for m in (a, b, c)] == [PluginState.READY] * 3 + + async def test_plugin_exported_service_is_still_propagated( + self, booted_kernel, tmp_path + ): + """Seuls les services injectés sont ignorés : un export du plugin passe.""" + manifest = _manifest(tmp_path, "exporter") + (manifest.plugin_dir / "src" / "main.py").write_text( + PLUGIN_SRC.replace( + " async def handle", + " async def on_load(self):\n" + " self._services['exported_svc'] = 'v1'\n\n" + " async def handle", + ) + ) + lm = LifecycleManager(manifest, booted_kernel.ctx) + await lm.load() + + assert booted_kernel.container.as_dict()["exported_svc"] == "v1" + assert booted_kernel.container.as_dict()["scheduler"] is booted_kernel.scheduler + + async def test_override_of_core_service_is_still_rejected( + self, booted_kernel, tmp_path + ): + """Un plugin qui remplace un service noyau par un AUTRE objet est bloqué.""" + manifest = _manifest(tmp_path, "evil") + (manifest.plugin_dir / "src" / "main.py").write_text( + PLUGIN_SRC.replace( + " async def handle", + " async def on_load(self):\n" + " self._services['scheduler'] = object()\n\n" + " async def handle", + ) + ) + lm = LifecycleManager(manifest, booted_kernel.ctx) + await lm.load() # 1er boot : le registre ne protège pas encore le noyau + _finish_boot(booted_kernel) + + with pytest.raises(LoadError, match="protégé"): + await lm.reload() + + +class TestFailedPluginIsRecoverable: + async def test_failed_reload_releases_what_the_plugin_registered( + self, booted_kernel, tmp_path + ): + manifest = _manifest(tmp_path, "leaky") + (manifest.plugin_dir / "src" / "main.py").write_text( + PLUGIN_SRC.replace( + " async def handle", + " async def on_load(self):\n" + " self.ctx.services['scheduler'].add_job(\n" + " self.handle, 'interval', job_id='leaky_tick', seconds=3600)\n" + " raise RuntimeError('boom')\n\n" + " async def handle", + ) + ) + lm = LifecycleManager(manifest, booted_kernel.ctx) + with pytest.raises(LoadError): + await lm.load() + + assert lm.state == PluginState.FAILED + assert "leaky_tick" not in scheduler_module._JOB_REGISTRY + assert booted_kernel.scheduler._scheduler.get_job("leaky_tick") is None + + async def test_unload_is_allowed_from_failed(self, booted_kernel, tmp_path): + lm = LifecycleManager(_manifest(tmp_path, "a"), booted_kernel.ctx) + await lm.load() + lm._sm.force(PluginState.FAILED) + + await lm.unload() + + assert lm.state == PluginState.UNLOADED + assert lm._instance is None + + async def test_reload_is_allowed_from_failed(self, booted_kernel, tmp_path): + lm = LifecycleManager(_manifest(tmp_path, "a"), booted_kernel.ctx) + await lm.load() + lm._sm.force(PluginState.FAILED) + + await lm.reload() + + assert lm.state == PluginState.READY diff --git a/tests/unit/kernel/test_state_machine.py b/tests/unit/kernel/test_state_machine.py index a64e954..067eaad 100644 --- a/tests/unit/kernel/test_state_machine.py +++ b/tests/unit/kernel/test_state_machine.py @@ -124,13 +124,31 @@ def test_transition_from_failed(self, sm): sm.transition("error") assert sm.state == PluginState.FAILED - # From FAILED, only reset is valid + # From FAILED : "load" reste invalide, mais unload/reload/reset sont possibles with pytest.raises(InvalidTransition): sm.transition("load") sm.transition("reset") assert sm.state == PluginState.UNLOADED + def test_failed_can_be_unloaded(self, sm): + """Un plugin FAILED doit pouvoir être déchargé (donc nettoyé).""" + sm.transition("load") + sm.transition("error") + sm.transition("unload") + assert sm.state == PluginState.UNLOADING + sm.transition("ok") + assert sm.state == PluginState.UNLOADED + + def test_failed_can_be_reloaded(self, sm): + """Un plugin FAILED doit pouvoir être rechargé (nouvel essai).""" + sm.transition("load") + sm.transition("error") + sm.transition("reload") + assert sm.state == PluginState.RELOADING + sm.transition("ok") + assert sm.state == PluginState.READY + def test_callback_on_change(self): """Test on_change callback.""" callbacks = [] diff --git a/xcore/__version__.py b/xcore/__version__.py index 4f12822..9afbf3f 100644 --- a/xcore/__version__.py +++ b/xcore/__version__.py @@ -1,4 +1,4 @@ -__version__ = "2.6.3" -__version_info__ = (2, 6, 3) +__version__ = "2.6.4" +__version_info__ = (2, 6, 4) __author__ = "xcore contributors" __license__ = "MIT" diff --git a/xcore/kernel/runtime/lifecycle.py b/xcore/kernel/runtime/lifecycle.py index 9d6c3a3..c37ce4f 100644 --- a/xcore/kernel/runtime/lifecycle.py +++ b/xcore/kernel/runtime/lifecycle.py @@ -24,7 +24,7 @@ from ..api.context import PluginContext from ..api.contract import BasePlugin from ..observability import get_logger -from .plugin_gc import PluginResourceTracker +from .plugin_gc import PluginResourceTracker, _ScopedScheduler from .state_machine import PluginState, StateMachine logger = get_logger("xcore.runtime.lifecycle") @@ -72,6 +72,13 @@ def __init__( self._resource_tracker: PluginResourceTracker | None = None self._spawned_tasks: list[asyncio.Task] = [] + # Services injectés par le noyau dans ctx.services (après wrapping + # tenant/ramasse-miette), au moment du load : nom → objet exact reçu. + # Sert à distinguer, dans propagate_services(), ce que le plugin a + # *exporté* de ce qu'il a simplement reçu en injection — ces derniers + # ne doivent jamais être réécrits dans le container partagé. + self._injected_services: dict[str, Any] = {} + self._sm = StateMachine( manifest.name, on_change=self._on_state_change, @@ -133,6 +140,7 @@ async def load(self) -> None: logger.exception( "plugin load failed", plugin=self.manifest.name, error=str(e) ) + await self._cleanup_after_failure() raise LoadError(f"[{self.manifest.name}] Loading failed: {e}") from e async def _do_load(self) -> None: @@ -206,6 +214,7 @@ async def _do_load(self) -> None: ) self._resource_tracker = tracker ctx._task_sink = self._spawned_tasks + self._injected_services = dict(ctx.services) if hasattr(self._instance, "_inject_context"): await self._instance._inject_context(ctx) @@ -316,6 +325,10 @@ async def reload(self) -> None: logger.info("plugin reloaded", plugin=self.manifest.name) except Exception as e: self._sm.transition("error") + logger.error( + "plugin reload failed", plugin=self.manifest.name, error=str(e) + ) + await self._cleanup_after_failure() raise LoadError(f"[{self.manifest.name}] failed reload : {e}") from e # ── Unload ──────────────────────────────────────────────── @@ -330,8 +343,24 @@ async def unload(self) -> None: self._sm.transition("error") raise - async def _do_unload(self) -> None: - if self._instance: + async def _cleanup_after_failure(self) -> None: + """ + Ramasse-miette forcé après un load()/reload() raté, sans rappeler les + hooks du plugin (son état interne est incohérent). Sans ça, ce qu'il a + déjà enregistré (jobs, abonnements, tâches, module dans sys.modules) + restait en place alors que le plugin passe en FAILED. + """ + try: + await self._do_unload(run_hooks=False) + except Exception as e: # pragma: no cover — best-effort + logger.error( + "forced cleanup after failure failed", + plugin=self.manifest.name, + error=str(e), + ) + + async def _do_unload(self, *, run_hooks: bool = True) -> None: + if self._instance and run_hooks: # Best-effort : on essaie les hooks du plugin, mais une erreur ici # ne doit pas empêcher le ramasse-miette forcé ci-dessous — c'est # justement le filet de sécurité pour un on_unload/on_stop bâclé. @@ -422,6 +451,17 @@ def _collect_middlewares(self) -> None: # ── Propagation des services (fix #3 v1) ────────────────── + def _is_injected(self, name: str, obj: Any) -> bool: + """Vrai si `obj` est exactement l'objet injecté par le noyau sous `name`.""" + return name in self._injected_services and self._injected_services[name] is obj + + @staticmethod + def _unwrap_proxy(obj: Any) -> Any: + """Retire tous les niveaux de proxy de ramasse-miette autour d'un service.""" + while isinstance(obj, _ScopedScheduler): + obj = obj._real + return obj + def propagate_services(self, *, is_reload: bool = False) -> dict: """ Propage les services enregistrés par le plugin vers le container partagé. @@ -453,6 +493,11 @@ def propagate_services(self, *, is_reload: bool = False) -> dict: # la source de vérité et assure la protection des services noyau. if self._registry: for name, obj in instance_services.items(): + # Service reçu en injection (db, cache, scheduler…), pas exporté + # par ce plugin : ni à enregistrer ni à réécrire dans le + # container partagé. + if self._is_injected(name, obj): + continue svc_meta = manifest_services_config.get(name, {}) scope = svc_meta.get("scope", "public") @@ -482,10 +527,10 @@ def propagate_services(self, *, is_reload: bool = False) -> dict: # différent, c'est une vraie tentative malveillante : on # relève l'erreur telle quelle. `obj` peut être un proxy de # ramasse-miette (_ScopedScheduler, etc.) posé par ce même - # LifecycleManager autour du service réel — on déballe un - # niveau (`_real`) avant de comparer, sinon l'identité ne - # matcherait jamais pour un service ainsi enveloppé. - underlying = getattr(obj, "_real", obj) + # LifecycleManager autour du service réel — on déballe tous + # les niveaux de proxy avant de comparer, sinon l'identité + # ne matcherait jamais pour un service ainsi enveloppé. + underlying = self._unwrap_proxy(obj) if self._registry.is_registered_as( name, obj ) or self._registry.is_registered_as(name, underlying): @@ -500,7 +545,11 @@ def propagate_services(self, *, is_reload: bool = False) -> dict: # Fallback de sécurité si le registre est absent (pour les tests ou configs minimales) # On définit une liste minimale de services à protéger protected = {"db", "cache", "scheduler", "events", "hooks", "database"} - if collisions := set(instance_services.keys()) & protected: + if collisions := { + k + for k, v in instance_services.items() + if k in protected and not self._is_injected(k, v) + }: raise PermissionError( f"[{self.manifest.name}] Tentative d'écrasement de services " f"noyau sans registre : {collisions}" @@ -517,18 +566,27 @@ def propagate_services(self, *, is_reload: bool = False) -> dict: }, ) - # Mise à jour du container local (rétro-compatibilité et accès rapide) + # Mise à jour du container local (rétro-compatibilité et accès rapide). + # `self._services` EST le dict partagé du ServiceContainer : on n'y + # écrit que ce que le plugin a exporté, jamais les services du noyau + # reçus en injection — sinon un reload y laissait le proxy de + # ramasse-miette du plugin à la place du vrai service (et chaque reload + # empilait un proxy de plus, cassant le reload/load suivant de + # n'importe quel plugin). + exported = { + k: v for k, v in instance_services.items() if not self._is_injected(k, v) + } if is_reload: - self._services.update(instance_services) + self._services.update(exported) logger.info( "services updated on reload", plugin=self.manifest.name, - services=sorted(instance_services.keys()), + services=sorted(exported.keys()), ) else: - new_keys = set(instance_services.keys()) - set(self._services.keys()) + new_keys = set(exported.keys()) - set(self._services.keys()) for k in new_keys: - self._services[k] = instance_services[k] + self._services[k] = exported[k] if new_keys: logger.info( "services registered", diff --git a/xcore/kernel/runtime/state_machine.py b/xcore/kernel/runtime/state_machine.py index f0a39d5..e671978 100644 --- a/xcore/kernel/runtime/state_machine.py +++ b/xcore/kernel/runtime/state_machine.py @@ -8,6 +8,8 @@ READY ──reload► RELOADING──► READY * ──error──► FAILED FAILED ──reset──► UNLOADED + FAILED ──unload► UNLOADING──► UNLOADED (nettoyage forcé d'un plugin planté) + FAILED ──reload► RELOADING──► READY (nouvel essai) """ from __future__ import annotations @@ -36,7 +38,14 @@ class PluginState(str, Enum): }, PluginState.UNLOADING: {"ok": PluginState.UNLOADED, "error": PluginState.FAILED}, PluginState.RELOADING: {"ok": PluginState.READY, "error": PluginState.FAILED}, - PluginState.FAILED: {"reset": PluginState.UNLOADED}, + # FAILED doit rester récupérable : sans "unload", un plugin planté en plein + # reload ne pouvait plus jamais être déchargé (donc nettoyé) ni rechargé — + # "reset" n'est appelé par aucun code du noyau. + PluginState.FAILED: { + "reset": PluginState.UNLOADED, + "unload": PluginState.UNLOADING, + "reload": PluginState.RELOADING, + }, } From 76475e2c66082ce5d39663761a55aa9133a2c88b Mon Sep 17 00:00:00 2001 From: traoreera Date: Thu, 1 Oct 2026 21:04:12 +0000 Subject: [PATCH 10/15] =?UTF-8?q?fix(scheduler):=20jobs=20lib=C3=A9r=C3=A9?= =?UTF-8?q?s=20au=20unload=20apr=C3=A8s=20le=20boot=20+=20ids=20namespac?= =?UTF-8?q?=C3=A9s=20par=20plugin=20(v2.6.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Après le boot, PluginContext.get_service() interrogeait le registre en premier, qui renvoie les services noyau BRUTS : le proxy de suivi du scheduler était contourné (job conservé après unload, instance + module épinglés) et les wrappers tenant-aware db/cache aussi (isolation tenant perdue pour un plugin rechargé). - get_service() sert les services noyau depuis le contexte du plugin (PluginRegistry.is_core_service) ; les exports de plugins passent toujours par le registre avec leur scoping - _ScopedScheduler préfixe les job_id par plugin (:) : deux plugins avec un job « cleanup » ne s'écrasent plus - tests de régression (vrai registre/container/scheduler) --- CHANGELOG.md | 11 + doc/changelog.md | 11 + pyproject.toml | 2 +- tests/unit/kernel/test_context.py | 21 ++ tests/unit/kernel/test_lifecycle.py | 2 +- tests/unit/kernel/test_plugin_gc_scheduler.py | 215 ++++++++++++++++++ tests/unit/test_registry_index.py | 12 + xcore/__version__.py | 4 +- xcore/kernel/api/context.py | 12 +- xcore/kernel/runtime/plugin_gc.py | 44 +++- xcore/registry/index.py | 6 + 11 files changed, 326 insertions(+), 14 deletions(-) create mode 100644 tests/unit/kernel/test_plugin_gc_scheduler.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 15ec97f..8894468 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.6.5] - 2026-10-01 + +### Fixed +- **After boot, the scheduler's forced cleanup silently stopped working — and so did tenant isolation for `get_service()`**: `PluginContext.get_service()` consulted the `PluginRegistry` first. During `load_all()` the registry is still empty of core services, so it fell back to the plugin's own `ctx.services` — where the kernel had put the plugin's `_ScopedScheduler` tracker proxy (and the tenant-aware `db`/`cache` wrappers when tenancy is on). But once `supervisor.boot()` finished and ran `register_core_service()`, the registry started answering with the *raw* core objects. Any plugin loaded or reloaded after boot that did `self.get_service("scheduler").add_job(...)` therefore bypassed the tracker: the job stayed in `_JOB_REGISTRY` and APScheduler after unload, kept firing against a dead plugin, and pinned the unloaded instance, its module and its state in memory forever (verified: instance still alive after `gc.collect()`). The same bypass handed out the raw `db`/`cache` instead of `TenantAwareDB`/`TenantAwareCache`, so a plugin reloaded after boot escaped tenant isolation. + `get_service()` now serves **kernel** services from the plugin's own context (new `PluginRegistry.is_core_service()` tells kernel services from plugin exports) and keeps resolving plugin-exported services — with their `public`/`private` scoping — through the registry. +- **Scheduler job ids collided across plugins**: `_JOB_REGISTRY` and APScheduler are global and keyed by the bare job id (`fn.__name__` by default), so two plugins that both register a `cleanup` job overwrote each other, and unloading one removed the other's job. `_ScopedScheduler` now namespaces ids as `:` for `add_job`, `cron` and `interval`; plugins keep using their own unprefixed ids (`remove_job`/`pause_job`/`resume_job` translate). Visible side effect: job ids shown by `scheduler.jobs()` and in the Redis job store are now prefixed with the plugin name. + +### Added +- `PluginRegistry.is_core_service(name)`. +- `tests/unit/kernel/test_plugin_gc_scheduler.py`: job released and instance garbage-collectable after unload *after boot*, no job accumulation across reloads, no collision between plugins, `interval` decorator, tenant wrapping preserved by `get_service()` after boot. + ## [2.6.4] - 2026-10-01 ### Fixed diff --git a/doc/changelog.md b/doc/changelog.md index 15ec97f..8894468 100644 --- a/doc/changelog.md +++ b/doc/changelog.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.6.5] - 2026-10-01 + +### Fixed +- **After boot, the scheduler's forced cleanup silently stopped working — and so did tenant isolation for `get_service()`**: `PluginContext.get_service()` consulted the `PluginRegistry` first. During `load_all()` the registry is still empty of core services, so it fell back to the plugin's own `ctx.services` — where the kernel had put the plugin's `_ScopedScheduler` tracker proxy (and the tenant-aware `db`/`cache` wrappers when tenancy is on). But once `supervisor.boot()` finished and ran `register_core_service()`, the registry started answering with the *raw* core objects. Any plugin loaded or reloaded after boot that did `self.get_service("scheduler").add_job(...)` therefore bypassed the tracker: the job stayed in `_JOB_REGISTRY` and APScheduler after unload, kept firing against a dead plugin, and pinned the unloaded instance, its module and its state in memory forever (verified: instance still alive after `gc.collect()`). The same bypass handed out the raw `db`/`cache` instead of `TenantAwareDB`/`TenantAwareCache`, so a plugin reloaded after boot escaped tenant isolation. + `get_service()` now serves **kernel** services from the plugin's own context (new `PluginRegistry.is_core_service()` tells kernel services from plugin exports) and keeps resolving plugin-exported services — with their `public`/`private` scoping — through the registry. +- **Scheduler job ids collided across plugins**: `_JOB_REGISTRY` and APScheduler are global and keyed by the bare job id (`fn.__name__` by default), so two plugins that both register a `cleanup` job overwrote each other, and unloading one removed the other's job. `_ScopedScheduler` now namespaces ids as `:` for `add_job`, `cron` and `interval`; plugins keep using their own unprefixed ids (`remove_job`/`pause_job`/`resume_job` translate). Visible side effect: job ids shown by `scheduler.jobs()` and in the Redis job store are now prefixed with the plugin name. + +### Added +- `PluginRegistry.is_core_service(name)`. +- `tests/unit/kernel/test_plugin_gc_scheduler.py`: job released and instance garbage-collectable after unload *after boot*, no job accumulation across reloads, no collision between plugins, `interval` decorator, tenant wrapping preserved by `get_service()` after boot. + ## [2.6.4] - 2026-10-01 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index a143f9c..eb0f9a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "XCoreRuntime" -version = "2.6.4" +version = "2.6.5" description = "Plugin-first orchestration framework built on FastAPI" authors = [ { name = "Eliezer Traore", email = "68350805+traoreera@users.noreply.github.com" }, diff --git a/tests/unit/kernel/test_context.py b/tests/unit/kernel/test_context.py index fc9f6af..ec5019c 100644 --- a/tests/unit/kernel/test_context.py +++ b/tests/unit/kernel/test_context.py @@ -63,3 +63,24 @@ def test_env_default_empty(self): def test_config_default_empty(self): ctx = PluginContext(name="auth") assert ctx.config == {} + + + def test_get_service_core_service_served_from_plugin_context(self): + """Un service noyau vient du contexte du plugin (proxy/tenant), pas du registre brut.""" + registry = MagicMock() + registry.is_core_service.return_value = True + registry.get_service.return_value = "raw_scheduler" + ctx = PluginContext( + name="auth", services={"scheduler": "scoped_scheduler"}, registry=registry + ) + + assert ctx.get_service("scheduler") == "scoped_scheduler" + registry.get_service.assert_not_called() + + def test_get_service_plugin_export_still_resolved_by_registry(self): + registry = MagicMock() + registry.is_core_service.return_value = False + registry.get_service.return_value = "exported" + ctx = PluginContext(name="auth", services={"cart": "stale"}, registry=registry) + + assert ctx.get_service("cart") == "exported" diff --git a/tests/unit/kernel/test_lifecycle.py b/tests/unit/kernel/test_lifecycle.py index 2e66c30..0ccefa4 100644 --- a/tests/unit/kernel/test_lifecycle.py +++ b/tests/unit/kernel/test_lifecycle.py @@ -420,7 +420,7 @@ async def _inject_context(self, ctx): await manager.load() real_scheduler.add_job.assert_called_once() await manager.unload() - real_scheduler.remove_job.assert_called_once_with("nightly") + real_scheduler.remove_job.assert_called_once_with("test_plugin:nightly") @pytest.mark.asyncio async def test_unload_removes_health_check(self, mock_manifest): diff --git a/tests/unit/kernel/test_plugin_gc_scheduler.py b/tests/unit/kernel/test_plugin_gc_scheduler.py new file mode 100644 index 0000000..74ec7ec --- /dev/null +++ b/tests/unit/kernel/test_plugin_gc_scheduler.py @@ -0,0 +1,215 @@ +""" +Régression : le ramasse-miette forcé du scheduler doit fonctionner aussi APRÈS +le boot, quand le registre contient les services noyau bruts, et deux plugins +ne doivent pas se marcher dessus dans le registre de jobs global. +""" + +import gc +import weakref +from types import SimpleNamespace + +import pytest + +from xcore.configurations.sections import SchedulerConfig, ServicesConfig +from xcore.kernel.context import KernelContext +from xcore.kernel.events.bus import EventBus +from xcore.kernel.events.hooks import HookManager +from xcore.kernel.observability.health import HealthChecker +from xcore.kernel.runtime.lifecycle import LifecycleManager +from xcore.kernel.tenancy.services import TenantAwareDB +from xcore.registry.index import PluginRegistry +from xcore.services.container import ServiceContainer +from xcore.services.scheduler import service as scheduler_module +from xcore.services.scheduler.service import SchedulerService + +PLUGIN_TEMPLATE = """ +from xcore.kernel.api.contract import TrustedBase + +class Plugin(TrustedBase): + async def on_load(self): + self.blob = bytearray(100_000) +{body} + + async def tick(self): + return len(self.blob) + + async def handle(self, action, payload): + return {{"status": "ok"}} +""" + +VIA_GET_SERVICE = """ + self.get_service("scheduler").add_job( + self.tick, "interval", job_id="cleanup", seconds=3600 + ) +""" + + +def _manifest(tmp_path, name, body=VIA_GET_SERVICE): + plugin_dir = tmp_path / name + (plugin_dir / "src").mkdir(parents=True) + (plugin_dir / "src" / "main.py").write_text(PLUGIN_TEMPLATE.format(body=body)) + return SimpleNamespace( + name=name, + plugin_dir=plugin_dir, + entry_point="src/main.py", + resources=SimpleNamespace(timeout_seconds=10), + env={}, + requires=[], + extra={}, + ) + + +def _kernel(scheduler, tenancy=None, extra_services=None): + container = ServiceContainer(ServicesConfig()) + container._raw["scheduler"] = scheduler + container._raw.update(extra_services or {}) + registry = PluginRegistry() + ctx = KernelContext( + config=SimpleNamespace(tenancy=tenancy), + services=container, + registry=registry, + events=EventBus(), + hooks=HookManager(), + health=HealthChecker(), + ) + return ctx, container, registry + + +def _finish_boot(container, registry): + for name, svc in container.as_dict().items(): + registry.register_core_service(name, svc) + + +@pytest.fixture +async def scheduler(): + svc = SchedulerService(SchedulerConfig()) + await svc.init() + yield svc + scheduler_module._JOB_REGISTRY.clear() + await svc.shutdown() + + +class TestSchedulerReleasedAfterBoot: + async def test_job_registered_via_get_service_is_released_on_unload( + self, scheduler, tmp_path + ): + ctx, container, registry = _kernel(scheduler) + lm = LifecycleManager(_manifest(tmp_path, "p"), ctx) + _finish_boot(container, registry) # plugin (re)chargé APRÈS le boot + + await lm.load() + job_id = "p:cleanup" + assert job_id in scheduler_module._JOB_REGISTRY + assert scheduler._scheduler.get_job(job_id) is not None + ref = weakref.ref(lm._instance) + + await lm.unload() + gc.collect() + + assert job_id not in scheduler_module._JOB_REGISTRY + assert scheduler._scheduler.get_job(job_id) is None + assert ref() is None, "l'instance déchargée est encore référencée" + + async def test_reload_after_boot_does_not_accumulate_jobs( + self, scheduler, tmp_path + ): + ctx, container, registry = _kernel(scheduler) + lm = LifecycleManager(_manifest(tmp_path, "p"), ctx) + await lm.load() + _finish_boot(container, registry) + + for _ in range(3): + await lm.reload() + + assert [j for j in scheduler_module._JOB_REGISTRY if j.startswith("p:")] == [ + "p:cleanup" + ] + refs = scheduler_module._JOB_REGISTRY["p:cleanup"].__self__ + assert refs is lm._instance + + +class TestJobIdsAreNamespacedPerPlugin: + async def test_two_plugins_with_same_job_name_do_not_collide( + self, scheduler, tmp_path + ): + ctx, container, registry = _kernel(scheduler) + a = LifecycleManager(_manifest(tmp_path, "a"), ctx) + b = LifecycleManager(_manifest(tmp_path, "b"), ctx) + await a.load() + await b.load() + + assert {"a:cleanup", "b:cleanup"} <= set(scheduler_module._JOB_REGISTRY) + assert scheduler_module._JOB_REGISTRY["a:cleanup"].__self__ is a._instance + assert scheduler_module._JOB_REGISTRY["b:cleanup"].__self__ is b._instance + + async def test_unloading_one_plugin_keeps_the_other_plugins_job( + self, scheduler, tmp_path + ): + ctx, container, registry = _kernel(scheduler) + a = LifecycleManager(_manifest(tmp_path, "a"), ctx) + b = LifecycleManager(_manifest(tmp_path, "b"), ctx) + await a.load() + await b.load() + + await a.unload() + + assert "a:cleanup" not in scheduler_module._JOB_REGISTRY + assert "b:cleanup" in scheduler_module._JOB_REGISTRY + assert scheduler._scheduler.get_job("b:cleanup") is not None + + async def test_plugin_keeps_using_its_own_unprefixed_ids(self, scheduler, tmp_path): + body = """ + sch = self.get_service("scheduler") + sch.add_job(self.tick, "interval", job_id="cleanup", seconds=3600) + sch.add_job(self.tick, "interval", job_id="other", seconds=3600) + sch.remove_job("cleanup") +""" + ctx, _, _ = _kernel(scheduler) + lm = LifecycleManager(_manifest(tmp_path, "p", body), ctx) + await lm.load() + + assert "p:cleanup" not in scheduler_module._JOB_REGISTRY + assert "p:other" in scheduler_module._JOB_REGISTRY + + async def test_interval_decorator_is_namespaced_and_released( + self, scheduler, tmp_path + ): + body = """ + @self.get_service("scheduler").interval(seconds=3600) + async def heartbeat(): + return 1 +""" + ctx, _, _ = _kernel(scheduler) + lm = LifecycleManager(_manifest(tmp_path, "p", body), ctx) + await lm.load() + assert "p:heartbeat" in scheduler_module._JOB_REGISTRY + + await lm.unload() + assert "p:heartbeat" not in scheduler_module._JOB_REGISTRY + + +class TestCoreServicesKeepTheirPerPluginWrapping: + async def test_get_service_after_boot_is_still_tenant_aware( + self, scheduler, tmp_path + ): + """Après le boot, get_service('db') rendait le db brut (registre).""" + body = """ + self.db = self.get_service("db") +""" + tenancy = SimpleNamespace( + enabled=True, + isolate_db=True, + isolate_cache=False, + isolate_scheduler=False, + ) + raw_db = object() + ctx, container, registry = _kernel( + scheduler, tenancy=tenancy, extra_services={"db": raw_db} + ) + lm = LifecycleManager(_manifest(tmp_path, "p", body), ctx) + _finish_boot(container, registry) + + await lm.load() + + assert isinstance(lm._instance.db, TenantAwareDB) + assert lm._instance.db is not raw_db diff --git a/tests/unit/test_registry_index.py b/tests/unit/test_registry_index.py index 7efdc81..971d247 100644 --- a/tests/unit/test_registry_index.py +++ b/tests/unit/test_registry_index.py @@ -156,3 +156,15 @@ def test_unregister_cleans_services(self): assert not reg.has("plugin_x") with pytest.raises(KeyError): reg.get_service("x_svc") + + +def test_is_core_service_distinguishes_kernel_from_plugin_exports(): + from xcore.registry.index import PluginRegistry + + reg = PluginRegistry() + reg.register_core_service("scheduler", object()) + reg.register_service("shop", "cart", object()) + + assert reg.is_core_service("scheduler") is True + assert reg.is_core_service("cart") is False + assert reg.is_core_service("missing") is False diff --git a/xcore/__version__.py b/xcore/__version__.py index 9afbf3f..9ca7e11 100644 --- a/xcore/__version__.py +++ b/xcore/__version__.py @@ -1,4 +1,4 @@ -__version__ = "2.6.4" -__version_info__ = (2, 6, 4) +__version__ = "2.6.5" +__version_info__ = (2, 6, 5) __author__ = "xcore contributors" __license__ = "MIT" diff --git a/xcore/kernel/api/context.py b/xcore/kernel/api/context.py index eaf74d2..59f6300 100644 --- a/xcore/kernel/api/context.py +++ b/xcore/kernel/api/context.py @@ -71,8 +71,18 @@ def get_service(self, name: str) -> Any: Accès sécurisé à un service avec vérification de scoping via le registry si disponible, sinon via le container partagé. """ - # Priorité au registry pour le respect des scopes (public/private/protected) if self.registry: + # Service noyau (db, cache, scheduler…) : on sert la version injectée + # dans CE contexte — proxy de ramasse-miette du plugin, wrappers + # tenant-aware — et non l'objet brut du registre. Après le boot, le + # registre contient les services noyau bruts : passer par lui + # contournait le suivi des jobs au unload et l'isolation tenant. + if self.registry.is_core_service(name) is True: + svc = self.services.get(name) + if svc is not None: + return svc + + # Priorité au registry pour le respect des scopes (public/private/protected) try: return self.registry.get_service(name, requester=self.name) except (KeyError, PermissionError) as e: diff --git a/xcore/kernel/runtime/plugin_gc.py b/xcore/kernel/runtime/plugin_gc.py index 7df8323..726bd3b 100644 --- a/xcore/kernel/runtime/plugin_gc.py +++ b/xcore/kernel/runtime/plugin_gc.py @@ -33,12 +33,26 @@ class _ScopedScheduler: - """Proxy autour de SchedulerService : mémorise les job_id créés par CE plugin.""" + """ + Proxy autour de SchedulerService : mémorise les job_id créés par CE plugin. - def __init__(self, real: Any) -> None: + Les job_id sont préfixés par le nom du plugin (`:`) : le + registre de jobs du scheduler est global, donc deux plugins avec un job + `cleanup` s'écrasaient mutuellement — et le unload de l'un supprimait le + job de l'autre. Le plugin continue de manipuler ses ids non préfixés + (remove_job/pause_job/resume_job traduisent). + """ + + def __init__(self, real: Any, plugin_name: str | None = None) -> None: self._real = real + self._prefix = f"{plugin_name}:" if plugin_name else "" self._job_ids: set[str] = set() + def _scoped_id(self, job_id: str) -> str: + if not self._prefix or job_id.startswith(self._prefix): + return job_id + return f"{self._prefix}{job_id}" + def add_job( self, func: Callable, @@ -46,24 +60,36 @@ def add_job( job_id: str | None = None, **kwargs: Any, ) -> Any: - effective_id = job_id or getattr(func, "__name__", repr(func)) + effective_id = self._scoped_id(job_id or getattr(func, "__name__", repr(func))) self._job_ids.add(effective_id) - return self._real.add_job(func, trigger=trigger, job_id=job_id, **kwargs) + return self._real.add_job(func, trigger=trigger, job_id=effective_id, **kwargs) def cron(self, expression: str, job_id: str | None = None) -> Callable: def decorator(fn: Callable) -> Callable: - self._job_ids.add(job_id or fn.__name__) - return self._real.cron(expression, job_id=job_id)(fn) + effective_id = self._scoped_id(job_id or fn.__name__) + self._job_ids.add(effective_id) + return self._real.cron(expression, job_id=effective_id)(fn) return decorator def interval(self, **kwargs: Any) -> Callable: def decorator(fn: Callable) -> Callable: - self._job_ids.add(fn.__name__) - return self._real.interval(**kwargs)(fn) + self.add_job(fn, "interval", **kwargs) + return fn return decorator + def remove_job(self, job_id: str) -> None: + scoped = self._scoped_id(job_id) + self._job_ids.discard(scoped) + self._real.remove_job(scoped) + + def pause_job(self, job_id: str) -> None: + self._real.pause_job(self._scoped_id(job_id)) + + def resume_job(self, job_id: str) -> None: + self._real.resume_job(self._scoped_id(job_id)) + def cleanup(self, plugin_name: str) -> None: for job_id in self._job_ids: with contextlib.suppress(Exception): @@ -223,7 +249,7 @@ def __init__(self, plugin_name: str) -> None: def wrap_scheduler(self, real: Any) -> Any: if real is None: return real - proxy = _ScopedScheduler(real) + proxy = _ScopedScheduler(real, self._plugin_name) self._scoped.append(proxy) return proxy diff --git a/xcore/registry/index.py b/xcore/registry/index.py index d9ad112..581d3dd 100644 --- a/xcore/registry/index.py +++ b/xcore/registry/index.py @@ -135,6 +135,12 @@ def list_services(self) -> list[dict]: for name, meta in self._exported_services.items() ] + def is_core_service(self, service_name: str) -> bool: + """Vrai si `service_name` est un service noyau (enregistré par le kernel + via register_core_service) et non un service exporté par un plugin.""" + existing = self._exported_services.get(service_name) + return existing is not None and existing.get("plugin") == "kernel" + def is_registered_as(self, service_name: str, obj: Any) -> bool: """Vrai si `service_name` est déjà exporté et pointe vers CE MÊME objet (identité, pas égalité) — utile pour distinguer une simple ré-injection From 70466921226af518bea5114805058fdf72953a9a Mon Sep 17 00:00:00 2001 From: traoreera Date: Thu, 1 Oct 2026 21:13:12 +0000 Subject: [PATCH 11/15] =?UTF-8?q?fix(lifecycle):=20ce=20que=20le=20plugin?= =?UTF-8?q?=20laisse=20derri=C3=A8re=20lui=20au=20unload/reload=20(v2.6.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - les services exportés par le plugin sont retirés du dict partagé du ServiceContainer au unload (seulement ceux qu'il y a mis, jamais un service repris par un autre plugin) ; avant, le plugin mort restait appelable et épinglé - plugin_router / plugin_middlewares remis à zéro au unload, et middlewares remplacés (plus fusionnés) au load - les tâches ctx.spawn_task() annulées sont attendues (délai borné) avant le retrait du module ; un unload lancé depuis l'une d'elles ne s'auto-annule plus - spawn_task() retire les tâches terminées de la liste de suivi et journalise celles qui échouent --- CHANGELOG.md | 11 + doc/changelog.md | 11 + pyproject.toml | 2 +- .../kernel/test_lifecycle_unload_cleanup.py | 269 ++++++++++++++++++ xcore/__version__.py | 4 +- xcore/kernel/api/context.py | 27 +- xcore/kernel/runtime/lifecycle.py | 56 +++- 7 files changed, 370 insertions(+), 10 deletions(-) create mode 100644 tests/unit/kernel/test_lifecycle_unload_cleanup.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8894468..3d523db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.6.6] - 2026-10-01 + +### Fixed +- **Unloading a plugin left its exported services callable in the shared container**: `PluginRegistry.unregister()` only cleans the registry, but `propagate_services()` also writes a plugin's exported services into the `ServiceContainer`'s shared dict — where they stayed after unload, reachable by every other plugin through `ctx.services` and pinning the dead plugin (instance, module, state) in memory. `LifecycleManager` now remembers what *it* wrote and removes exactly those entries at unload — never one that another plugin has since replaced. +- **A plugin's HTTP router and middlewares survived unload and reload**: `plugin_router`/`plugin_middlewares` were never reset in `_do_unload()`, so an unloaded handler kept the old router (and, through its closures, the old module), and a reload whose new code no longer exposed a router kept serving the previous one. `_collect_middlewares()` also *merged* the new `add_state()` result into the old dict (`.update`), so removed middlewares lived on. Both are now cleared at unload and replaced, not merged, on load. +- **Cancelled background tasks were not awaited before the plugin's module was dropped**: `_do_unload()` called `task.cancel()` and moved on, so the tasks' `finally` blocks ran *after* `sys.modules` had been purged. Tasks created with `ctx.spawn_task()` are now awaited after cancellation (bounded by `_TASK_CANCEL_TIMEOUT_S`, 2 s; a task that swallows `CancelledError` is logged and no longer blocks the unload). An unload triggered from inside one of those tasks no longer cancels itself. +- **`ctx.spawn_task()` leaked every finished task**: the tracking list only ever grew for the lifetime of the plugin. Finished tasks are now dropped as they complete, and a task that fails logs `spawned task failed` instead of surfacing as an unretrieved exception at garbage-collection time. + +### Added +- `tests/unit/kernel/test_lifecycle_unload_cleanup.py`: router/middleware reset and replacement, exported-service removal (and respect for a service another plugin took over), cancelled-task cleanup ordering, tracking-list hygiene, unload from inside a spawned task. + ## [2.6.5] - 2026-10-01 ### Fixed diff --git a/doc/changelog.md b/doc/changelog.md index 8894468..3d523db 100644 --- a/doc/changelog.md +++ b/doc/changelog.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.6.6] - 2026-10-01 + +### Fixed +- **Unloading a plugin left its exported services callable in the shared container**: `PluginRegistry.unregister()` only cleans the registry, but `propagate_services()` also writes a plugin's exported services into the `ServiceContainer`'s shared dict — where they stayed after unload, reachable by every other plugin through `ctx.services` and pinning the dead plugin (instance, module, state) in memory. `LifecycleManager` now remembers what *it* wrote and removes exactly those entries at unload — never one that another plugin has since replaced. +- **A plugin's HTTP router and middlewares survived unload and reload**: `plugin_router`/`plugin_middlewares` were never reset in `_do_unload()`, so an unloaded handler kept the old router (and, through its closures, the old module), and a reload whose new code no longer exposed a router kept serving the previous one. `_collect_middlewares()` also *merged* the new `add_state()` result into the old dict (`.update`), so removed middlewares lived on. Both are now cleared at unload and replaced, not merged, on load. +- **Cancelled background tasks were not awaited before the plugin's module was dropped**: `_do_unload()` called `task.cancel()` and moved on, so the tasks' `finally` blocks ran *after* `sys.modules` had been purged. Tasks created with `ctx.spawn_task()` are now awaited after cancellation (bounded by `_TASK_CANCEL_TIMEOUT_S`, 2 s; a task that swallows `CancelledError` is logged and no longer blocks the unload). An unload triggered from inside one of those tasks no longer cancels itself. +- **`ctx.spawn_task()` leaked every finished task**: the tracking list only ever grew for the lifetime of the plugin. Finished tasks are now dropped as they complete, and a task that fails logs `spawned task failed` instead of surfacing as an unretrieved exception at garbage-collection time. + +### Added +- `tests/unit/kernel/test_lifecycle_unload_cleanup.py`: router/middleware reset and replacement, exported-service removal (and respect for a service another plugin took over), cancelled-task cleanup ordering, tracking-list hygiene, unload from inside a spawned task. + ## [2.6.5] - 2026-10-01 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index eb0f9a0..7e7b01e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "XCoreRuntime" -version = "2.6.5" +version = "2.6.6" description = "Plugin-first orchestration framework built on FastAPI" authors = [ { name = "Eliezer Traore", email = "68350805+traoreera@users.noreply.github.com" }, diff --git a/tests/unit/kernel/test_lifecycle_unload_cleanup.py b/tests/unit/kernel/test_lifecycle_unload_cleanup.py new file mode 100644 index 0000000..65b65c5 --- /dev/null +++ b/tests/unit/kernel/test_lifecycle_unload_cleanup.py @@ -0,0 +1,269 @@ +""" +Régression : ce qu'un plugin laisse derrière lui au unload / reload — router et +middlewares, services exportés dans le container partagé, tâches de fond. +""" + +import asyncio +import gc +import weakref +from types import SimpleNamespace + +from xcore.configurations.sections import ServicesConfig +from xcore.kernel.context import KernelContext +from xcore.kernel.events.bus import EventBus +from xcore.kernel.events.hooks import HookManager +from xcore.kernel.observability.health import HealthChecker +from xcore.kernel.runtime.lifecycle import LifecycleManager +from xcore.registry.index import PluginRegistry +from xcore.services.container import ServiceContainer + +HEADER = """ +import asyncio +from xcore.kernel.api.contract import TrustedBase +""" + + +def _plugin(body: str) -> str: + return ( + HEADER + + "\nclass Plugin(TrustedBase):\n" + + body + + "\n async def handle(self, action, payload):\n" + + ' return {"status": "ok"}\n' + ) + + +def _manifest(tmp_path, name="p"): + plugin_dir = tmp_path / name + (plugin_dir / "src").mkdir(parents=True) + return SimpleNamespace( + name=name, + plugin_dir=plugin_dir, + entry_point="src/main.py", + resources=SimpleNamespace(timeout_seconds=10), + env={}, + requires=[], + extra={}, + ) + + +def _write(manifest, body): + (manifest.plugin_dir / "src" / "main.py").write_text(_plugin(body)) + + +def _manager(manifest): + container = ServiceContainer(ServicesConfig()) + ctx = KernelContext( + config=SimpleNamespace(tenancy=None), + services=container, + registry=PluginRegistry(), + events=EventBus(), + hooks=HookManager(), + health=HealthChecker(), + ) + return LifecycleManager(manifest, ctx), container + + +ROUTER_V1 = """ + def get_router(self): + return object() + + def add_state(self): + return {"mw_v1": 1} +""" + +NO_ROUTER_V2 = """ + def add_state(self): + return {"mw_v2": 2} +""" + + +class TestRouterAndMiddlewares: + async def test_unload_clears_router_and_middlewares(self, tmp_path): + manifest = _manifest(tmp_path) + _write(manifest, ROUTER_V1) + lm, _ = _manager(manifest) + await lm.load() + assert lm.plugin_router is not None + assert lm.plugin_middlewares == {"mw_v1": 1} + + await lm.unload() + + assert lm.plugin_router is None + assert lm.plugin_middlewares == {} + + async def test_reload_drops_router_the_new_code_no_longer_exposes(self, tmp_path): + manifest = _manifest(tmp_path) + _write(manifest, ROUTER_V1) + lm, _ = _manager(manifest) + await lm.load() + + _write(manifest, NO_ROUTER_V2) + await lm.reload() + + assert lm.plugin_router is None + + async def test_reload_replaces_middlewares_instead_of_merging(self, tmp_path): + manifest = _manifest(tmp_path) + _write(manifest, ROUTER_V1) + lm, _ = _manager(manifest) + await lm.load() + + _write(manifest, NO_ROUTER_V2) + await lm.reload() + + assert lm.plugin_middlewares == {"mw_v2": 2} + + +EXPORTS = """ + async def on_load(self): + self.blob = bytearray(100_000) + self._services["probe_svc"] = self +""" + + +class TestExportedServices: + async def test_unload_removes_exported_service_from_shared_container( + self, tmp_path + ): + manifest = _manifest(tmp_path) + _write(manifest, EXPORTS) + lm, container = _manager(manifest) + await lm.load() + assert "probe_svc" in container.as_dict() + ref = weakref.ref(lm._instance) + + await lm.unload() + gc.collect() + + assert "probe_svc" not in container.as_dict() + assert ref() is None, "le plugin déchargé est encore référencé" + + async def test_reload_swaps_in_the_new_instance(self, tmp_path): + manifest = _manifest(tmp_path) + _write(manifest, EXPORTS) + lm, container = _manager(manifest) + await lm.load() + first = container.as_dict()["probe_svc"] + + await lm.reload() + + assert container.as_dict()["probe_svc"] is lm._instance + assert container.as_dict()["probe_svc"] is not first + + async def test_unload_keeps_a_service_another_plugin_took_over(self, tmp_path): + manifest = _manifest(tmp_path) + _write(manifest, EXPORTS) + lm, container = _manager(manifest) + await lm.load() + takeover = object() + container.as_dict()["probe_svc"] = takeover + + await lm.unload() + + assert container.as_dict()["probe_svc"] is takeover + + +SPAWNING = """ + async def on_load(self): + self.cleaned = [] + self.ctx.spawn_task(self._worker(), name="worker") + + async def _worker(self): + try: + await asyncio.sleep(3600) + finally: + await asyncio.sleep(0) # nettoyage asynchrone du plugin + self.cleaned.append("worker") +""" + + +class TestSpawnedTasks: + async def test_unload_waits_for_cancelled_tasks_to_finish_their_cleanup( + self, tmp_path + ): + manifest = _manifest(tmp_path) + _write(manifest, SPAWNING) + lm, _ = _manager(manifest) + await lm.load() + await asyncio.sleep(0) # laisse la tâche démarrer + instance = lm._instance + + await lm.unload() + + # le `finally` de la tâche s'est exécuté AVANT le retour de unload() + assert instance.cleaned == ["worker"] + + async def test_finished_tasks_are_dropped_from_the_tracking_list(self, tmp_path): + manifest = _manifest(tmp_path) + _write( + manifest, + """ + async def on_load(self): + async def short(): + return 1 + for _ in range(5): + self.ctx.spawn_task(short()) +""", + ) + lm, _ = _manager(manifest) + await lm.load() + assert len(lm._spawned_tasks) == 5 + + await asyncio.sleep(0.01) + + assert lm._spawned_tasks == [] + + async def test_task_that_ignores_cancellation_does_not_block_unload(self, tmp_path): + manifest = _manifest(tmp_path) + _write( + manifest, + """ + async def on_load(self): + self.stop = False + self.ctx.spawn_task(self._stubborn(), name="stubborn") + + async def _stubborn(self): + while not self.stop: + try: + await asyncio.sleep(3600) + except asyncio.CancelledError: + pass +""", + ) + lm, _ = _manager(manifest) + lm._TASK_CANCEL_TIMEOUT_S = 0.05 + await lm.load() + await asyncio.sleep(0) + instance = lm._instance + stubborn = next(t for t in asyncio.all_tasks() if t.get_name() == "stubborn") + + try: + await asyncio.wait_for(lm.unload(), timeout=2) + assert lm._instance is None + finally: # laisse la tâche récalcitrante se terminer pour fermer le loop + instance.stop = True + stubborn.cancel() + await asyncio.wait([stubborn], timeout=1) + + async def test_unload_from_inside_a_spawned_task_does_not_cancel_itself( + self, tmp_path + ): + manifest = _manifest(tmp_path) + _write( + manifest, + """ + async def on_load(self): + self.done = asyncio.Event() +""", + ) + lm, _ = _manager(manifest) + await lm.load() + + async def unload_from_task(): + lm._spawned_tasks.append(asyncio.current_task()) + await lm.unload() + + await asyncio.create_task(unload_from_task()) + + assert lm._instance is None diff --git a/xcore/__version__.py b/xcore/__version__.py index 9ca7e11..bb1b100 100644 --- a/xcore/__version__.py +++ b/xcore/__version__.py @@ -1,4 +1,4 @@ -__version__ = "2.6.5" -__version_info__ = (2, 6, 5) +__version__ = "2.6.6" +__version_info__ = (2, 6, 6) __author__ = "xcore contributors" __license__ = "MIT" diff --git a/xcore/kernel/api/context.py b/xcore/kernel/api/context.py index 59f6300..c9799fc 100644 --- a/xcore/kernel/api/context.py +++ b/xcore/kernel/api/context.py @@ -9,9 +9,14 @@ from __future__ import annotations import asyncio +import contextlib from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Awaitable, Callable, Coroutine +from ..observability import get_logger + +logger = get_logger("xcore.api.context") + if TYPE_CHECKING: from ...registry import PluginRegistry from ..observability import HealthChecker, MetricsRegistry, Tracer @@ -62,8 +67,26 @@ def spawn_task(self, coro: Coroutine, name: str | None = None) -> asyncio.Task: qu'on_unload/on_stop l'ait fait ou non. """ task = asyncio.create_task(coro, name=name) - if self._task_sink is not None: - self._task_sink.append(task) + sink = self._task_sink + if sink is not None: + sink.append(task) + plugin = self.name + + def _on_done(t: asyncio.Task) -> None: + # Une tâche terminée n'a plus rien à annuler : sans ça la liste + # grossissait pendant toute la vie du plugin, et chaque tâche + # finie restait référencée (avec son résultat / son exception). + with contextlib.suppress(ValueError): + sink.remove(t) + if not t.cancelled() and t.exception() is not None: + logger.error( + "spawned task failed", + plugin=plugin, + task=t.get_name(), + error=str(t.exception()), + ) + + task.add_done_callback(_on_done) return task def get_service(self, name: str) -> Any: diff --git a/xcore/kernel/runtime/lifecycle.py b/xcore/kernel/runtime/lifecycle.py index c37ce4f..969e4ec 100644 --- a/xcore/kernel/runtime/lifecycle.py +++ b/xcore/kernel/runtime/lifecycle.py @@ -41,6 +41,10 @@ class LifecycleManager: PROTECTED_SERVICES = {"db", "cache", "scheduler", "events", "hooks", "database"} + # Délai accordé aux tâches annulées au unload pour exécuter leurs `finally` + # avant que le module du plugin soit retiré de sys.modules. + _TASK_CANCEL_TIMEOUT_S = 2.0 + def __init__( self, manifest, # PluginManifest @@ -78,6 +82,9 @@ def __init__( # *exporté* de ce qu'il a simplement reçu en injection — ces derniers # ne doivent jamais être réécrits dans le container partagé. self._injected_services: dict[str, Any] = {} + # Services que CE plugin a écrits dans le container partagé (nom → objet), + # retirés au unload pour ne pas laisser un plugin mort appelable. + self._exported_to_container: dict[str, Any] = {} self._sm = StateMachine( manifest.name, @@ -359,6 +366,31 @@ async def _cleanup_after_failure(self) -> None: error=str(e), ) + async def _cancel_spawned_tasks(self) -> None: + """ + Annule les tâches créées via ctx.spawn_task() et ATTEND qu'elles se + terminent (délai borné) : un simple cancel() ne fait que programmer + l'annulation, leurs `finally` s'exécutaient donc après que le module du + plugin ait été retiré de sys.modules. + """ + current = asyncio.current_task() # un unload peut venir d'une de ces tâches + pending = [t for t in self._spawned_tasks if t is not current and not t.done()] + self._spawned_tasks = [] + for task in pending: + task.cancel() + if not pending: + return + _, still_running = await asyncio.wait( + pending, timeout=self._TASK_CANCEL_TIMEOUT_S + ) + if still_running: + logger.warning( + "spawned tasks ignored cancellation", + plugin=self.manifest.name, + tasks=sorted(t.get_name() for t in still_running), + timeout_s=self._TASK_CANCEL_TIMEOUT_S, + ) + async def _do_unload(self, *, run_hooks: bool = True) -> None: if self._instance and run_hooks: # Best-effort : on essaie les hooks du plugin, mais une erreur ici @@ -379,14 +411,27 @@ async def _do_unload(self, *, run_hooks: bool = True) -> None: self._resource_tracker.cleanup() self._resource_tracker = None - for task in self._spawned_tasks: - if not task.done(): - task.cancel() - self._spawned_tasks = [] + await self._cancel_spawned_tasks() if self._registry is not None: self._registry.unregister(self.manifest.name) + # `unregister()` ne nettoie que le registre : les services que le plugin + # a exportés restaient aussi dans le dict partagé du ServiceContainer, + # donc appelables par les autres plugins (et le plugin déchargé épinglé + # en mémoire). On ne retire que ce que CE plugin y a mis, tel quel. + for name, obj in self._exported_to_container.items(): + if self._services.get(name) is obj: + self._services.pop(name, None) + self._exported_to_container = {} + self._injected_services = {} + + # Router HTTP / middlewares du plugin : sans ça, un handler déchargé + # gardait l'ancien router (et via ses closures l'ancien module), et un + # reload dont le nouveau code n'en expose plus gardait l'ancien actif. + self.plugin_router = None + self.plugin_middlewares = {} + module_name = f"xcore_plugin_{self.manifest.name}" # Nettoie le module principal et le package namespace sys.modules.pop(f"{module_name}.main", None) @@ -438,7 +483,7 @@ def _collect_middlewares(self) -> None: try: middlewares = add_middlewares() if middlewares is not None: - self.plugin_middlewares.update(middlewares) + self.plugin_middlewares = dict(middlewares) logger.info( "middlewares collected", plugin=self.manifest.name, @@ -576,6 +621,7 @@ def propagate_services(self, *, is_reload: bool = False) -> dict: exported = { k: v for k, v in instance_services.items() if not self._is_injected(k, v) } + self._exported_to_container.update(exported) if is_reload: self._services.update(exported) logger.info( From 9ff7ff28ae101971e5ffd40fcd0e917a71ab858e Mon Sep 17 00:00:00 2001 From: traoreera Date: Thu, 1 Oct 2026 21:50:49 +0000 Subject: [PATCH 12/15] =?UTF-8?q?fix(runtime):=20routes=20de=20plugin=20re?= =?UTF-8?q?tir=C3=A9es=20au=20unload/reload,=20instances=20Ephemeral=20iso?= =?UTF-8?q?l=C3=A9es,=20GC=20forc=C3=A9=20apr=C3=A8s=20unload=20(v2.6.7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Xcore._unmount_plugin_router : app.routes n'a pas de setter (Starlette) et les _IncludedRouter de FastAPI >= 0.14x n'ont pas de .path — après un unload/reload les anciennes routes restaient montées et servaient l'ancien code. Les routes ajoutées par include_router() sont mémorisées et retirées à l'identité ; préfixe exact (/plugins/shop n'emporte plus /plugins/shop2) ; montage partagé entre boot et reload - instances poolées (Ephemeral) : espace de noms sys.modules propre et pas de désinscription du registre à chaque unload (les instances sœurs perdaient leurs imports paresseux, le plugin disparaissait du registre) - supervisor.boot : les services exportés par des plugins ne sont plus marqués « kernel » (le plugin exportateur ne pouvait plus être rechargé) - collecte GC différée et regroupée après unload/reload d'un plugin persistant, puis contrôle weakref : warning « plugin instance still referenced after unload » avec les types de référents ; plugins.gc_after_unload (défaut true) --- CHANGELOG.md | 13 ++ doc/changelog.md | 13 ++ pyproject.toml | 2 +- tests/unit/kernel/test_ephemeral_isolation.py | 150 +++++++++++++++ .../kernel/test_plugin_release_watcher.py | 173 ++++++++++++++++++ .../test_supervisor_exported_services.py | 89 +++++++++ tests/unit/test_registry_index.py | 23 ++- tests/unit/test_xcore_plugin_routes.py | 139 ++++++++++++++ xcore/__init__.py | 101 ++++++---- xcore/__version__.py | 4 +- xcore/configurations/loader.py | 1 + xcore/configurations/sections.py | 3 + xcore/kernel/runtime/ephemeral_handler.py | 6 + xcore/kernel/runtime/lifecycle.py | 113 +++++++++++- xcore/kernel/runtime/supervisor.py | 7 + xcore/kernel/runtime/warm_pool.py | 1 + xcore/registry/index.py | 5 + 17 files changed, 802 insertions(+), 41 deletions(-) create mode 100644 tests/unit/kernel/test_ephemeral_isolation.py create mode 100644 tests/unit/kernel/test_plugin_release_watcher.py create mode 100644 tests/unit/kernel/test_supervisor_exported_services.py create mode 100644 tests/unit/test_xcore_plugin_routes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d523db..45191ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.6.7] - 2026-10-01 + +### Fixed +- **A plugin's HTTP routes were never removed on unload or reload — the old code kept serving**: `Xcore._unmount_plugin_router()` did `app.routes = [...]`, but `routes` is a read-only property in Starlette (`AttributeError: property 'routes' ... has no setter`, swallowed by the `EventBus` as an `event handler error`), and even with a working setter it filtered on `route.path`, an attribute FastAPI ≥ 0.14x's `_IncludedRouter` does not have. A reload therefore kept the previous router mounted (the old closures served requests and pinned the old plugin generation in memory) and `unload`/`disable` left a disabled plugin's endpoints reachable. Mounting now goes through a single `_mount_plugin_router()` that records the route objects `include_router()` added; unmounting removes exactly those (in place, invalidating FastAPI's route cache) plus any route whose `path` is the plugin prefix or below it. The prefix match is now segment-exact: unmounting `/plugins/shop` no longer removes `/plugins/shop2`. Boot and reload now share the same mount logic — the reload path used to always wrap the router in a second `/plugins/` prefix while boot only did so when the router wasn't already prefixed — and the "plugin routes mounted" log no longer reads an undefined `wrapper` for a router that is already prefixed. +- **Unloading one instance of an Ephemeral plugin broke its sibling instances and unregistered the plugin**: every pooled instance of the same manifest shared one `sys.modules` namespace (`xcore_plugin_`), and every instance's unload purged it and called `registry.unregister()`. Unloading or evicting one warm-pool instance made the others' lazy relative imports fail with `ModuleNotFoundError: No module named 'xcore_plugin_'`, and removed the still-active plugin from the registry until the next instance happened to re-register it. Pooled instances (`LifecycleManager(..., pooled=True)`, used by `WarmPool` and `EphemeralHandler`) now get their own module namespace and leave the registry alone; `EphemeralHandler.stop()` unregisters the plugin once, when the plugin itself stops. +- **A plugin that exports a service could not be reloaded after boot**: `PluginSupervisor.boot()` step 5 registered the entire `ServiceContainer` as kernel-protected services — including the services plugins had just propagated into it — so the exporting plugin's next reload failed with `Impossible d'écraser le service protégé '' (propriétaire actuel: kernel)`. Services already owned by a plugin in the registry (`PluginRegistry.service_owner()`) are now left with their owner. + +### Added +- **Forced garbage collection after unload/reload, with a leak report**: a plugin instance lives in reference cycles (class, module, context, tasks), so reference counting never frees it and Python's automatic collector only reaches it after a full collection — measured on 3.14.7: a dead cycle promoted to the old generation was still alive after 11.5 million allocations on a 3-million-object heap (0 full collections ran), and the old generation of a reloaded plugin (module, state, buffers) stayed resident all that time. `LifecycleManager` now schedules one `gc.collect()` shortly after a persistent plugin is unloaded or reloaded (`_GC_DELAY_S`, 1 s; several unloads within the window share a single collection), then checks via a `weakref` that the old instance is gone. If it is not, it logs `plugin instance still referenced after unload` with the types of its referrers — the signature of a raw `asyncio.create_task`, a callback registered outside `ctx`, or a service held elsewhere. Ephemeral pool instances are exempt (short-lived young cycles; a full collection per call would cost more than it frees). + A full collection is stop-the-world: ~15 ms on a 300 000-object heap, ~850 ms on 3 million. It happens once per unload/reload burst, never on the request path. Opt out with `plugins.gc_after_unload: false` in `integration.yaml` (default `true`). +- `PluginRegistry.service_owner(name)`; `LifecycleManager(..., pooled=...)`. +- Regression tests: `tests/unit/test_xcore_plugin_routes.py` (mount/unmount/remount against a real FastAPI app), `tests/unit/kernel/test_ephemeral_isolation.py`, `tests/unit/kernel/test_supervisor_exported_services.py` (real supervisor boot + reload), `tests/unit/kernel/test_plugin_release_watcher.py`. + ## [2.6.6] - 2026-10-01 ### Fixed diff --git a/doc/changelog.md b/doc/changelog.md index 3d523db..45191ba 100644 --- a/doc/changelog.md +++ b/doc/changelog.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.6.7] - 2026-10-01 + +### Fixed +- **A plugin's HTTP routes were never removed on unload or reload — the old code kept serving**: `Xcore._unmount_plugin_router()` did `app.routes = [...]`, but `routes` is a read-only property in Starlette (`AttributeError: property 'routes' ... has no setter`, swallowed by the `EventBus` as an `event handler error`), and even with a working setter it filtered on `route.path`, an attribute FastAPI ≥ 0.14x's `_IncludedRouter` does not have. A reload therefore kept the previous router mounted (the old closures served requests and pinned the old plugin generation in memory) and `unload`/`disable` left a disabled plugin's endpoints reachable. Mounting now goes through a single `_mount_plugin_router()` that records the route objects `include_router()` added; unmounting removes exactly those (in place, invalidating FastAPI's route cache) plus any route whose `path` is the plugin prefix or below it. The prefix match is now segment-exact: unmounting `/plugins/shop` no longer removes `/plugins/shop2`. Boot and reload now share the same mount logic — the reload path used to always wrap the router in a second `/plugins/` prefix while boot only did so when the router wasn't already prefixed — and the "plugin routes mounted" log no longer reads an undefined `wrapper` for a router that is already prefixed. +- **Unloading one instance of an Ephemeral plugin broke its sibling instances and unregistered the plugin**: every pooled instance of the same manifest shared one `sys.modules` namespace (`xcore_plugin_`), and every instance's unload purged it and called `registry.unregister()`. Unloading or evicting one warm-pool instance made the others' lazy relative imports fail with `ModuleNotFoundError: No module named 'xcore_plugin_'`, and removed the still-active plugin from the registry until the next instance happened to re-register it. Pooled instances (`LifecycleManager(..., pooled=True)`, used by `WarmPool` and `EphemeralHandler`) now get their own module namespace and leave the registry alone; `EphemeralHandler.stop()` unregisters the plugin once, when the plugin itself stops. +- **A plugin that exports a service could not be reloaded after boot**: `PluginSupervisor.boot()` step 5 registered the entire `ServiceContainer` as kernel-protected services — including the services plugins had just propagated into it — so the exporting plugin's next reload failed with `Impossible d'écraser le service protégé '' (propriétaire actuel: kernel)`. Services already owned by a plugin in the registry (`PluginRegistry.service_owner()`) are now left with their owner. + +### Added +- **Forced garbage collection after unload/reload, with a leak report**: a plugin instance lives in reference cycles (class, module, context, tasks), so reference counting never frees it and Python's automatic collector only reaches it after a full collection — measured on 3.14.7: a dead cycle promoted to the old generation was still alive after 11.5 million allocations on a 3-million-object heap (0 full collections ran), and the old generation of a reloaded plugin (module, state, buffers) stayed resident all that time. `LifecycleManager` now schedules one `gc.collect()` shortly after a persistent plugin is unloaded or reloaded (`_GC_DELAY_S`, 1 s; several unloads within the window share a single collection), then checks via a `weakref` that the old instance is gone. If it is not, it logs `plugin instance still referenced after unload` with the types of its referrers — the signature of a raw `asyncio.create_task`, a callback registered outside `ctx`, or a service held elsewhere. Ephemeral pool instances are exempt (short-lived young cycles; a full collection per call would cost more than it frees). + A full collection is stop-the-world: ~15 ms on a 300 000-object heap, ~850 ms on 3 million. It happens once per unload/reload burst, never on the request path. Opt out with `plugins.gc_after_unload: false` in `integration.yaml` (default `true`). +- `PluginRegistry.service_owner(name)`; `LifecycleManager(..., pooled=...)`. +- Regression tests: `tests/unit/test_xcore_plugin_routes.py` (mount/unmount/remount against a real FastAPI app), `tests/unit/kernel/test_ephemeral_isolation.py`, `tests/unit/kernel/test_supervisor_exported_services.py` (real supervisor boot + reload), `tests/unit/kernel/test_plugin_release_watcher.py`. + ## [2.6.6] - 2026-10-01 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 7e7b01e..1113034 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "XCoreRuntime" -version = "2.6.6" +version = "2.6.7" description = "Plugin-first orchestration framework built on FastAPI" authors = [ { name = "Eliezer Traore", email = "68350805+traoreera@users.noreply.github.com" }, diff --git a/tests/unit/kernel/test_ephemeral_isolation.py b/tests/unit/kernel/test_ephemeral_isolation.py new file mode 100644 index 0000000..9fa37ee --- /dev/null +++ b/tests/unit/kernel/test_ephemeral_isolation.py @@ -0,0 +1,150 @@ +""" +Régression : plusieurs instances d'un même plugin Ephemeral coexistent (warm +pool, appels concurrents). Avant le correctif elles partageaient le même nom de +module dans `sys.modules` et chaque unload faisait `registry.unregister(plugin)` : +décharger UNE instance cassait les imports paresseux des instances sœurs +(`ModuleNotFoundError: No module named 'xcore_plugin_'`) et retirait du +registre un plugin pourtant toujours actif. +""" + +import sys +from types import SimpleNamespace + +from xcore.configurations.sections import EphemeralConfig, ServicesConfig +from xcore.kernel.context import KernelContext +from xcore.kernel.events.bus import EventBus +from xcore.kernel.events.hooks import HookManager +from xcore.kernel.observability.health import HealthChecker +from xcore.kernel.runtime.ephemeral_handler import EphemeralHandler +from xcore.kernel.runtime.lifecycle import LifecycleManager +from xcore.kernel.runtime.warm_pool import WarmPool +from xcore.registry.index import PluginRegistry +from xcore.services.container import ServiceContainer + +PLUGIN_SRC = """ +from xcore.kernel.api.contract import TrustedBase + +class Plugin(TrustedBase): + async def handle(self, action, payload): + from . import helper # import paresseux relatif + return {"status": "ok", "v": helper.VALUE} +""" + + +def _setup(tmp_path): + plugin_dir = tmp_path / "eph" + (plugin_dir / "src").mkdir(parents=True) + (plugin_dir / "src" / "main.py").write_text(PLUGIN_SRC) + (plugin_dir / "src" / "helper.py").write_text("VALUE = 42\n") + manifest = SimpleNamespace( + name="eph", + plugin_dir=plugin_dir, + entry_point="src/main.py", + resources=SimpleNamespace(timeout_seconds=10), + env={}, + requires=[], + extra={}, + ) + registry = PluginRegistry() + ctx = KernelContext( + config=SimpleNamespace(tenancy=None), + services=ServiceContainer(ServicesConfig()), + registry=registry, + events=EventBus(), + hooks=HookManager(), + health=HealthChecker(), + ) + return manifest, ctx, registry + + +class TestPooledLifecycleManagers: + async def test_unloading_one_instance_does_not_break_its_sibling(self, tmp_path): + manifest, ctx, _ = _setup(tmp_path) + a = LifecycleManager(manifest, ctx, pooled=True) + b = LifecycleManager(manifest, ctx, pooled=True) + await a.load() + await b.load() + + await a.unload() + + assert await b.call("x", {}) == {"status": "ok", "v": 42} + await b.unload() + + async def test_unloading_a_pooled_instance_keeps_the_registry_entry(self, tmp_path): + manifest, ctx, registry = _setup(tmp_path) + registry.register("eph", object()) + lm = LifecycleManager(manifest, ctx, pooled=True) + await lm.load() + + await lm.unload() + + assert registry.has("eph") + + async def test_pooled_instances_get_distinct_module_namespaces(self, tmp_path): + manifest, ctx, _ = _setup(tmp_path) + a = LifecycleManager(manifest, ctx, pooled=True) + b = LifecycleManager(manifest, ctx, pooled=True) + persistent = LifecycleManager(manifest, ctx) + + assert len({a._module_name, b._module_name, persistent._module_name}) == 3 + assert persistent._module_name == "xcore_plugin_eph" + + async def test_unload_only_purges_its_own_modules(self, tmp_path): + manifest, ctx, _ = _setup(tmp_path) + a = LifecycleManager(manifest, ctx, pooled=True) + b = LifecycleManager(manifest, ctx, pooled=True) + await a.load() + await b.load() + + await a.unload() + + assert not [m for m in sys.modules if m.startswith(a._module_name)] + assert f"{b._module_name}.main" in sys.modules + await b.unload() + + async def test_non_pooled_unload_still_unregisters_the_plugin(self, tmp_path): + manifest, ctx, registry = _setup(tmp_path) + registry.register("eph", object()) + lm = LifecycleManager(manifest, ctx) + await lm.load() + + await lm.unload() + + assert not registry.has("eph") + + +class TestWarmPoolInstances: + async def test_discarding_a_pool_instance_keeps_the_other_ones_working( + self, tmp_path + ): + manifest, ctx, _ = _setup(tmp_path) + pool = WarmPool(manifest=manifest, ctx=ctx, pool_size=2) + await pool.start() + try: + first = await pool.acquire() + second = await pool.acquire() + assert first._module_name != second._module_name + + await pool.discard(first) + + assert await second.call("x", {}) == {"status": "ok", "v": 42} + await pool.release(second) + finally: + await pool.shutdown() + + +class TestEphemeralHandlerRegistry: + async def test_stop_unregisters_the_plugin_from_the_registry(self, tmp_path): + manifest, ctx, registry = _setup(tmp_path) + registry.register("eph", object()) + handler = EphemeralHandler( + manifest=manifest, ctx=ctx, config=EphemeralConfig(pool_size=1) + ) + await handler.start() + assert registry.has("eph") # démarrer / servir ne la désinscrit pas + assert (await handler.call("x", {}))["v"] == 42 + assert registry.has("eph") + + await handler.stop() + + assert not registry.has("eph") diff --git a/tests/unit/kernel/test_plugin_release_watcher.py b/tests/unit/kernel/test_plugin_release_watcher.py new file mode 100644 index 0000000..4d9b472 --- /dev/null +++ b/tests/unit/kernel/test_plugin_release_watcher.py @@ -0,0 +1,173 @@ +""" +Après un unload/reload, une collecte du GC est forcée (différée, regroupée) et +les instances qui survivent sont signalées. Sans ça, un plugin déchargé — qui vit +dans des cycles de références — restait en mémoire jusqu'à la prochaine collecte +complète de Python, qui peut ne jamais venir sur un gros tas. +""" + +import asyncio +import gc +import logging +import weakref +from types import SimpleNamespace + +import pytest + +from xcore.configurations.sections import ServicesConfig +from xcore.kernel.context import KernelContext +from xcore.kernel.events.bus import EventBus +from xcore.kernel.events.hooks import HookManager +from xcore.kernel.observability.health import HealthChecker +from xcore.kernel.runtime import lifecycle as lifecycle_module +from xcore.kernel.runtime.lifecycle import LifecycleManager +from xcore.registry.index import PluginRegistry +from xcore.services.container import ServiceContainer + +PLUGIN_SRC = """ +from xcore.kernel.api.contract import TrustedBase + +class Plugin(TrustedBase): + async def on_load(self): + self.me = self # cycle : le comptage de références seul ne libère pas + self.blob = bytearray(100_000) + + async def handle(self, action, payload): + return {"status": "ok"} +""" + + +@pytest.fixture(autouse=True) +def fast_delay(monkeypatch): + monkeypatch.setattr(lifecycle_module, "_GC_DELAY_S", 0.02) + watcher = lifecycle_module._release_watcher + watcher._pending, watcher._handle, watcher._loop = [], None, None + yield + watcher._pending, watcher._handle, watcher._loop = [], None, None + + +def _manager(tmp_path, name="p", gc_after_unload=True, pooled=False): + plugin_dir = tmp_path / name + (plugin_dir / "src").mkdir(parents=True) + (plugin_dir / "src" / "main.py").write_text(PLUGIN_SRC) + manifest = SimpleNamespace( + name=name, + plugin_dir=plugin_dir, + entry_point="src/main.py", + resources=SimpleNamespace(timeout_seconds=10), + env={}, + requires=[], + extra={}, + ) + ctx = KernelContext( + config=SimpleNamespace(tenancy=None, gc_after_unload=gc_after_unload), + services=ServiceContainer(ServicesConfig()), + registry=PluginRegistry(), + events=EventBus(), + hooks=HookManager(), + health=HealthChecker(), + ) + return LifecycleManager(manifest, ctx, pooled=pooled) + + +async def test_dead_plugin_generation_is_collected_without_manual_gc(tmp_path): + gc.disable() # le GC automatique ne doit pas être ce qui libère l'instance + try: + lm = _manager(tmp_path) + await lm.load() + ref = weakref.ref(lm._instance) + + await lm.unload() + assert ref() is not None # le cycle la retient jusqu'à la collecte différée + + await asyncio.sleep(0.1) + assert ref() is None + finally: + gc.enable() + + +async def test_reload_collects_the_previous_generation(tmp_path): + gc.disable() + try: + lm = _manager(tmp_path) + await lm.load() + old = weakref.ref(lm._instance) + + await lm.reload() + await asyncio.sleep(0.1) + + assert old() is None + assert lm._instance is not None + finally: + gc.enable() + + +async def test_instance_still_referenced_after_unload_is_reported(tmp_path, caplog): + lm = _manager(tmp_path) + await lm.load() + leak = lm._instance # quelque chose la retient encore (tâche brute, callback…) + + with caplog.at_level(logging.WARNING): + await lm.unload() + await asyncio.sleep(0.1) + + messages = [r.getMessage() for r in caplog.records] + assert any("still referenced after unload" in m for m in messages), messages + assert leak is not None + + +async def test_clean_unload_does_not_warn(tmp_path, caplog): + lm = _manager(tmp_path) + await lm.load() + + with caplog.at_level(logging.WARNING): + await lm.unload() + await asyncio.sleep(0.1) + + assert not [r for r in caplog.records if "still referenced" in r.getMessage()] + + +async def test_unloads_within_the_window_share_one_collection(tmp_path, monkeypatch): + calls = [] + real_collect = gc.collect + monkeypatch.setattr( + lifecycle_module.gc, "collect", lambda *a: calls.append(1) or real_collect(*a) + ) + managers = [_manager(tmp_path, f"p{i}") for i in range(3)] + for lm in managers: + await lm.load() + + for lm in managers: + await lm.unload() + await asyncio.sleep(0.1) + + assert len(calls) == 1 + + +async def test_disabled_by_config(tmp_path): + lm = _manager(tmp_path, gc_after_unload=False) + await lm.load() + + await lm.unload() + + assert lifecycle_module._release_watcher._pending == [] + assert lifecycle_module._release_watcher._handle is None + + +async def test_pooled_ephemeral_instances_are_not_watched(tmp_path): + lm = _manager(tmp_path, pooled=True) + await lm.load() + + await lm.unload() + + assert lifecycle_module._release_watcher._pending == [] + + +def test_gc_after_unload_defaults_to_enabled(): + from xcore.configurations.loader import ConfigLoader + from xcore.configurations.sections import PluginConfig + + assert PluginConfig().gc_after_unload is True + assert ConfigLoader._parse_plugins({}).gc_after_unload is True + assert ( + ConfigLoader._parse_plugins({"gc_after_unload": False}).gc_after_unload is False + ) diff --git a/tests/unit/kernel/test_supervisor_exported_services.py b/tests/unit/kernel/test_supervisor_exported_services.py new file mode 100644 index 0000000..f08d635 --- /dev/null +++ b/tests/unit/kernel/test_supervisor_exported_services.py @@ -0,0 +1,89 @@ +""" +Régression : un plugin qui EXPORTE un service (dans `self._services`) doit rester +rechargeable après le boot. + +`PluginSupervisor.boot()` enregistrait, à son étape 5, tout le contenu du +ServiceContainer comme service noyau protégé — y compris les services que les +plugins venaient d'y propager. Le propriétaire devenait « kernel » et le reload +du plugin qui l'exportait échouait avec « Impossible d'écraser le service +protégé ». +""" + +from types import SimpleNamespace + +import pytest + +from xcore.configurations.sections import ServicesConfig +from xcore.kernel.context import KernelContext +from xcore.kernel.events.bus import EventBus +from xcore.kernel.events.hooks import HookManager +from xcore.kernel.observability.health import HealthChecker +from xcore.kernel.runtime.supervisor import PluginSupervisor +from xcore.registry.index import PluginRegistry +from xcore.services.container import ServiceContainer + +MANIFEST = """ +name: exporter +version: 1.0.0 +execution_mode: trusted +entry_point: src/main.py +""" + +SRC = """ +from xcore.kernel.api.contract import TrustedBase + +class Plugin(TrustedBase): + async def on_load(self): + self._services["exported_svc"] = self + + async def handle(self, action, payload): + return {"status": "ok"} +""" + + +@pytest.fixture +async def booted(tmp_path): + plugins = tmp_path / "plugins" + (plugins / "exporter" / "src").mkdir(parents=True) + (plugins / "exporter" / "plugin.yaml").write_text(MANIFEST) + (plugins / "exporter" / "src" / "main.py").write_text(SRC) + + container = ServiceContainer(ServicesConfig()) + container._raw["cache"] = object() # un service noyau + registry = PluginRegistry() + ctx = KernelContext( + config=SimpleNamespace( + directory=str(plugins), + tenancy=None, + strict_trusted=False, + secret_key=b"test", + ), + services=container, + registry=registry, + events=EventBus(), + hooks=HookManager(), + health=HealthChecker(), + ) + supervisor = PluginSupervisor(ctx) + await supervisor.boot() + yield SimpleNamespace(supervisor=supervisor, container=container, registry=registry) + await supervisor.shutdown() + + +async def test_plugin_is_loaded_and_its_export_stays_owned_by_the_plugin(booted): + assert "exporter" in booted.supervisor.list_plugins() + exported = [ + s for s in booted.registry.list_services() if s["name"] == "exported_svc" + ] + assert exported and exported[0]["plugin"] == "exporter" + assert booted.registry.is_core_service("cache") is True + assert booted.registry.is_core_service("exported_svc") is False + + +async def test_plugin_exporting_a_service_can_be_reloaded_after_boot(booted): + await booted.supervisor.reload("exporter") + await booted.supervisor.reload("exporter") # le 2e reload cassait aussi + + handler = booted.supervisor._loader.get("exporter") + assert handler.state.value == "ready" + assert booted.container.as_dict()["exported_svc"] is handler._instance diff --git a/tests/unit/test_registry_index.py b/tests/unit/test_registry_index.py index 971d247..b0e3026 100644 --- a/tests/unit/test_registry_index.py +++ b/tests/unit/test_registry_index.py @@ -7,6 +7,7 @@ class TestPluginRegistry: def _make(self): from xcore.registry.index import PluginRegistry + return PluginRegistry() def test_register_and_has(self): @@ -71,7 +72,9 @@ def test_register_service_protected_overwrite_raises(self): def test_get_service_private_denied(self): reg = self._make() - reg.register_service("owner_plugin", "private_svc", MagicMock(), scope="private") + reg.register_service( + "owner_plugin", "private_svc", MagicMock(), scope="private" + ) with pytest.raises(PermissionError): reg.get_service("private_svc", requester="other_plugin") @@ -135,7 +138,11 @@ def test_search(self): reg = self._make() handler = MagicMock() handler.manifest = None - reg.register("auth_plugin", handler, metadata={"description": "auth service", "author": "me"}) + reg.register( + "auth_plugin", + handler, + metadata={"description": "auth service", "author": "me"}, + ) result = reg.search("auth") assert len(result) >= 1 @@ -168,3 +175,15 @@ def test_is_core_service_distinguishes_kernel_from_plugin_exports(): assert reg.is_core_service("scheduler") is True assert reg.is_core_service("cart") is False assert reg.is_core_service("missing") is False + + +def test_service_owner_reports_kernel_plugin_or_none(): + from xcore.registry.index import PluginRegistry + + reg = PluginRegistry() + reg.register_core_service("scheduler", object()) + reg.register_service("shop", "cart", object()) + + assert reg.service_owner("scheduler") == "kernel" + assert reg.service_owner("cart") == "shop" + assert reg.service_owner("missing") is None diff --git a/tests/unit/test_xcore_plugin_routes.py b/tests/unit/test_xcore_plugin_routes.py new file mode 100644 index 0000000..21c7eba --- /dev/null +++ b/tests/unit/test_xcore_plugin_routes.py @@ -0,0 +1,139 @@ +""" +Régression : montage / retrait des routes FastAPI d'un plugin au unload et au +reload (`Xcore._mount_plugin_router`, `_unmount_plugin_router`, +`_remount_plugin_router`). + +Avant le correctif, `_unmount_plugin_router` faisait `app.routes = [...]` — +propriété sans setter chez Starlette (AttributeError, avalée par l'EventBus) — et +filtrait sur `route.path`, attribut que les `_IncludedRouter` de FastAPI ≥ 0.14x +n'ont pas : après un unload ou un reload les anciennes routes restaient montées +et continuaient de servir l'ancien code du plugin. +""" + +from types import SimpleNamespace + +import pytest +from fastapi import APIRouter, FastAPI +from fastapi.testclient import TestClient + +from xcore import Xcore + + +def _router(answer: int) -> APIRouter: + router = APIRouter() + + @router.get("/ping") + async def ping(): + return {"answer": answer} + + return router + + +@pytest.fixture +def xcore_app(): + app = FastAPI() + x = object.__new__(Xcore) + x._app = app + x._config = SimpleNamespace( + app=SimpleNamespace(plugin_prefix="/plugins", plugin_tags=[]) + ) + x._logger = SimpleNamespace(info=lambda *a, **k: None) + x._plugin_routes = {} + x.plugins = SimpleNamespace(_loader=SimpleNamespace(get=lambda name: None)) + return x, app, TestClient(app) + + +def _mount(x, app, name, answer): + x._mount_plugin_router(app, name, _router(answer), "/plugins", []) + + +class TestUnmount: + def test_unmount_removes_the_plugin_routes(self, xcore_app): + x, app, client = xcore_app + _mount(x, app, "shop", 1) + assert client.get("/plugins/shop/ping").json() == {"answer": 1} + + x._unmount_plugin_router("shop") + + assert client.get("/plugins/shop/ping").status_code == 404 + + def test_unmount_does_not_touch_a_plugin_with_a_longer_name(self, xcore_app): + """`/plugins/shop` est un préfixe textuel de `/plugins/shop2`.""" + x, app, client = xcore_app + _mount(x, app, "shop", 1) + _mount(x, app, "shop2", 2) + + x._unmount_plugin_router("shop") + + assert client.get("/plugins/shop/ping").status_code == 404 + assert client.get("/plugins/shop2/ping").json() == {"answer": 2} + + def test_unmount_without_app_is_a_noop(self, xcore_app): + x, _, _ = xcore_app + x._app = None + x._unmount_plugin_router("shop") # ne lève pas + + def test_unmount_invalidates_the_openapi_schema(self, xcore_app): + x, app, client = xcore_app + _mount(x, app, "shop", 1) + assert "/plugins/shop/ping" in client.get("/openapi.json").json()["paths"] + + x._unmount_plugin_router("shop") + + assert "/plugins/shop/ping" not in client.get("/openapi.json").json()["paths"] + + +class TestRemountAfterReload: + def test_remount_serves_the_new_router_not_the_old_one(self, xcore_app): + x, app, client = xcore_app + _mount(x, app, "shop", 1) + new_handler = SimpleNamespace(plugin_router=_router(2)) + x.plugins = SimpleNamespace(_loader=SimpleNamespace(get=lambda n: new_handler)) + + x._remount_plugin_router("shop") + + assert client.get("/plugins/shop/ping").json() == {"answer": 2} + paths = [p for p in client.get("/openapi.json").json()["paths"] if "shop" in p] + assert paths == ["/plugins/shop/ping"] # pas de doublon + + def test_remount_drops_routes_when_the_new_code_has_no_router(self, xcore_app): + x, app, client = xcore_app + _mount(x, app, "shop", 1) + x.plugins = SimpleNamespace( + _loader=SimpleNamespace(get=lambda n: SimpleNamespace(plugin_router=None)) + ) + + x._remount_plugin_router("shop") + + assert client.get("/plugins/shop/ping").status_code == 404 + + def test_remount_after_unload_of_the_handler_leaves_no_route(self, xcore_app): + x, app, client = xcore_app + _mount(x, app, "shop", 1) + + def _raise(name): + raise KeyError(name) + + x.plugins = SimpleNamespace(_loader=SimpleNamespace(get=_raise)) + + x._remount_plugin_router("shop") + + assert client.get("/plugins/shop/ping").status_code == 404 + + +class TestMount: + def test_router_already_prefixed_with_plugins_is_not_double_prefixed( + self, xcore_app + ): + x, app, client = xcore_app + router = APIRouter(prefix="/plugins/custom") + + @router.get("/ping") + async def ping(): + return {"answer": 3} + + x._mount_plugin_router(app, "custom", router, "/plugins", []) + + assert client.get("/plugins/custom/ping").json() == {"answer": 3} + x._unmount_plugin_router("custom") + assert client.get("/plugins/custom/ping").status_code == 404 diff --git a/xcore/__init__.py b/xcore/__init__.py index d9ed44e..15398be 100644 --- a/xcore/__init__.py +++ b/xcore/__init__.py @@ -20,7 +20,7 @@ from __future__ import annotations import contextlib -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from fastapi import FastAPI @@ -133,6 +133,10 @@ def __init__(self, config_path: str | None = None): self._app: "FastAPI | None" = ( None # référence conservée pour remount après reload ) + # Objets route réellement ajoutés à l'app par plugin — la seule façon + # fiable de les retirer, quelle que soit la façon dont la version de + # FastAPI installée stocke un router inclus (voir _mount_plugin_router). + self._plugin_routes: dict[str, list[Any]] = {} self._logger = get_logger("xcore") @@ -281,13 +285,44 @@ async def shutdown(self) -> None: self._booted = False self._logger.info("xcore stopped") + def _mount_plugin_router( + self, app, plugin_name: str, plugin_router, prefix: str, tags: list[str] + ) -> str: + """ + Monte le router d'un plugin sous `/` et mémorise les + objets route que `include_router()` a ajoutés à l'app. + + Les retrouver par leur `path` ne marche plus : depuis FastAPI 0.14x, + `include_router()` stocke un `_IncludedRouter` sans attribut `path`. On + compare donc les routes de l'app avant/après l'inclusion. + """ + from fastapi import APIRouter + + plugin_prefix = f"{prefix}/{plugin_name}" + mounted: Any = plugin_router + if not getattr(plugin_router, "prefix", "").startswith("/plugins/"): + # Préfixe automatique si le plugin n'a pas déjà /plugins/... + mounted = APIRouter(prefix=plugin_prefix, tags=tags) + mounted.include_router(plugin_router) + + before = {id(r) for r in app.router.routes} + app.include_router(mounted) + self._plugin_routes[plugin_name] = [ + r for r in app.router.routes if id(r) not in before + ] + app.openapi_schema = None # force regen du schéma OpenAPI + return getattr(mounted, "prefix", plugin_prefix) + def _unmount_plugin_router(self, plugin_name: str) -> None: """ Retire de l'app FastAPI toutes les routes montées pour ce plugin. - FastAPI n'offre pas de désinscription native d'un routeur — on filtre - donc `app.routes`, seule approche possible. Utilisé au unload/disable - et en première étape du remount lors d'un reload. + FastAPI n'offre pas de désinscription native d'un routeur. On retire les + objets route mémorisés au montage (`_mount_plugin_router`), plus — pour + les versions où les routes exposent un `path` — celles dont le chemin est + exactement le préfixe du plugin ou en dessous (`/plugins/shop` ne doit + pas emporter `/plugins/shop2`). Utilisé au unload/disable et en première + étape du remount lors d'un reload. """ app = self._app if app is None: @@ -295,12 +330,22 @@ def _unmount_plugin_router(self, plugin_name: str) -> None: prefix = self._config.app.plugin_prefix or "/plugins" plugin_prefix = f"{prefix}/{plugin_name}" + tracked = {id(r) for r in self._plugin_routes.pop(plugin_name, [])} + + def belongs(route) -> bool: + if id(route) in tracked: + return True + path = getattr(route, "path", None) + return isinstance(path, str) and ( + path == plugin_prefix or path.startswith(plugin_prefix + "/") + ) - app.routes = [ - r - for r in app.routes - if not getattr(r, "path", "").startswith(plugin_prefix) - ] + # `app.routes` est une propriété sans setter (Starlette) : on modifie la + # liste du router en place. + app.router.routes[:] = [r for r in app.router.routes if not belongs(r)] + mark_changed = getattr(app.router, "_mark_routes_changed", None) + if callable(mark_changed): # invalide le cache de routes de FastAPI ≥ 0.14x + mark_changed() app.openapi_schema = None # force regen du schéma OpenAPI def _remount_plugin_router(self, plugin_name: str) -> None: @@ -312,7 +357,6 @@ def _remount_plugin_router(self, plugin_name: str) -> None: self._unmount_plugin_router(plugin_name) prefix = self._config.app.plugin_prefix or "/plugins" - plugin_prefix = f"{prefix}/{plugin_name}" # Récupère le nouveau router depuis le handler rechargé try: @@ -324,22 +368,20 @@ def _remount_plugin_router(self, plugin_name: str) -> None: if plugin_router is None: return - from fastapi import APIRouter - - wrapper = APIRouter( - prefix=plugin_prefix, - tags=(self._config.app.plugin_tags or []), + mounted_prefix = self._mount_plugin_router( + app, + plugin_name, + plugin_router, + prefix, + self._config.app.plugin_tags or [], ) - wrapper.include_router(plugin_router) - app.include_router(wrapper) - app.openapi_schema = None # force regen du schéma OpenAPI n_routes = len(getattr(plugin_router, "routes", [])) self._logger.info( "plugin routes remounted after reload", plugin=plugin_name, routes=n_routes, - prefix=plugin_prefix, + prefix=mounted_prefix, ) def _attach_router( @@ -366,24 +408,19 @@ def _attach_router( plugin_routers = self.plugins.collect_plugin_routers() for plugin_name, plugin_router in plugin_routers: # Monte sous /plugins// + le prefix du router du plugin - prefixed_router = plugin_router - if not getattr(plugin_router, "prefix", "").startswith("/plugins/"): - # Préfixe automatique si le plugin n'a pas déjà /plugins/... - from fastapi import APIRouter - - wrapper = APIRouter( - prefix=f"{prefix}/{plugin_name}", - tags=(self._config.app.plugin_tags or []) + (tags or []), - ) - wrapper.include_router(plugin_router) - prefixed_router = wrapper - app.include_router(prefixed_router) + mounted_prefix = self._mount_plugin_router( + app, + plugin_name, + plugin_router, + prefix or self._config.app.plugin_prefix or "/plugins", + (self._config.app.plugin_tags or []) + (tags or []), + ) n_routes = len(getattr(plugin_router, "routes", [])) self._logger.info( "plugin routes mounted", plugin=plugin_name, routes=n_routes, - prefix=wrapper.prefix, + prefix=mounted_prefix, ) for middleware in self.plugins.collect_app_state(): diff --git a/xcore/__version__.py b/xcore/__version__.py index bb1b100..782b400 100644 --- a/xcore/__version__.py +++ b/xcore/__version__.py @@ -1,4 +1,4 @@ -__version__ = "2.6.6" -__version_info__ = (2, 6, 6) +__version__ = "2.6.7" +__version_info__ = (2, 6, 7) __author__ = "xcore contributors" __license__ = "MIT" diff --git a/xcore/configurations/loader.py b/xcore/configurations/loader.py index 893af1f..156c5bc 100644 --- a/xcore/configurations/loader.py +++ b/xcore/configurations/loader.py @@ -197,6 +197,7 @@ def _parse_plugins(d: dict) -> PluginConfig: strict_trusted=d.get("strict_trusted", True), interval=d.get("interval", 2), entry_point=d.get("entry_point", "src/main.py"), + gc_after_unload=d.get("gc_after_unload", True), snapshot=d.get( "snapshot", { diff --git a/xcore/configurations/sections.py b/xcore/configurations/sections.py index 7de7420..194ec32 100644 --- a/xcore/configurations/sections.py +++ b/xcore/configurations/sections.py @@ -273,6 +273,9 @@ class PluginConfig: strict_trusted: bool = False interval: int = 2 # watcher interval (secondes) entry_point: str = "src/main.py" + # Force une collecte du GC (différée, regroupée) après un unload/reload de + # plugin et signale les instances qui survivent — voir _ReleaseWatcher. + gc_after_unload: bool = True snapshot: dict[str, Any] = field( default_factory=lambda: { "extensions": [".log", ".pyc", ".html"], diff --git a/xcore/kernel/runtime/ephemeral_handler.py b/xcore/kernel/runtime/ephemeral_handler.py index a9f1fab..ebe5f3b 100644 --- a/xcore/kernel/runtime/ephemeral_handler.py +++ b/xcore/kernel/runtime/ephemeral_handler.py @@ -109,6 +109,7 @@ async def start(self) -> None: manifest=self._manifest, ctx=self._ctx, caller=self._caller, + pooled=True, ) try: await lm.load() @@ -125,6 +126,11 @@ async def start(self) -> None: async def stop(self) -> None: """Arrête le warm pool et décharge toutes les instances.""" await self._pool.shutdown() + # Les instances poolées ne touchent pas au registre : c'est ce handler + # (le plugin, pas une instance) qui s'en désinscrit à l'arrêt. + registry = getattr(self._ctx, "registry", None) + if registry is not None: + registry.unregister(self._manifest.name) logger.info("ephemeral plugin stopped", plugin=self._manifest.name) # ── Appel ───────────────────────────────────────────────────────────────── diff --git a/xcore/kernel/runtime/lifecycle.py b/xcore/kernel/runtime/lifecycle.py index 969e4ec..fed896e 100644 --- a/xcore/kernel/runtime/lifecycle.py +++ b/xcore/kernel/runtime/lifecycle.py @@ -10,11 +10,14 @@ from __future__ import annotations import asyncio +import gc import importlib.util import inspect +import itertools import sys import time import types +import weakref from pathlib import Path from typing import TYPE_CHECKING, Any @@ -29,6 +32,83 @@ logger = get_logger("xcore.runtime.lifecycle") +# Délai avant la collecte qui suit un unload/reload : laisse les tâches annulées, +# les callbacks et les réponses en vol se terminer, et regroupe plusieurs unloads +# rapprochés en UNE seule collecte. +_GC_DELAY_S = 1.0 + + +class _ReleaseWatcher: + """ + Contrôle de libération des plugins déchargés. + + Une instance de plugin déchargée vit dans des cycles de références (classe, + module, contexte, tâches) : le comptage de références ne la libère jamais, et + le GC automatique de Python ne passe en génération ancienne qu'après + beaucoup d'allocations — mesuré : un cycle mort promu en vieille génération + n'était pas libéré après 11,5 M d'allocations sur un tas de 3 M d'objets. + Après un unload/reload, on force donc UNE collecte (différée et regroupée), + puis on vérifie via un weakref que l'instance a bien disparu — sinon quelque + chose la référence encore (tâche brute, callback enregistré en dehors du + ctx…) et on le signale, avec le type de ses référents. + """ + + def __init__(self) -> None: + self._pending: list[tuple[weakref.ref, str]] = [] + self._handle: asyncio.TimerHandle | None = None + self._loop: asyncio.AbstractEventLoop | None = None + + def watch(self, instance: Any, plugin: str, delay: float) -> None: + try: + ref = weakref.ref(instance) + except TypeError: # classe avec __slots__ sans __weakref__ + return + self._pending.append((ref, plugin)) + loop = asyncio.get_running_loop() + if self._handle is not None and self._loop is loop: + return # une collecte est déjà programmée : on s'y greffe + self._loop = loop + self._handle = loop.call_later(delay, self._collect) + + def _collect(self) -> None: + self._handle = None + pending, self._pending = self._pending, [] + started = time.perf_counter() + collected = gc.collect() + duration_ms = round((time.perf_counter() - started) * 1000, 1) + leaked = [(ref, plugin) for ref, plugin in pending if ref() is not None] + logger.debug( + "plugin garbage collected", + plugins=sorted({plugin for _, plugin in pending}), + unreachable_objects=collected, + duration_ms=duration_ms, + ) + for ref, plugin in leaked: + instance = ref() + if instance is None: + continue + referrers = sorted( + { + type(r).__name__ + for r in gc.get_referrers(instance) + if not isinstance(r, types.FrameType) + } + )[:8] + logger.warning( + "plugin instance still referenced after unload", + plugin=plugin, + referrers=referrers, + hint="a task, callback or service registered outside ctx still holds it", + ) + del instance + + +_release_watcher = _ReleaseWatcher() + +# Identifiants d'instances « poolées » (plugins Ephemeral) : chacune reçoit son +# propre espace de noms de modules. +_POOLED_INSTANCE_IDS = itertools.count(1) + class LoadError(Exception): """Erreur fatale lors du chargement d'un plugin Trusted.""" @@ -50,9 +130,23 @@ def __init__( manifest, # PluginManifest ctx: "KernelContext", caller=None, + *, + pooled: bool = False, ) -> None: + """ + `pooled=True` : instance jetable d'un plugin Ephemeral (plusieurs + instances du même manifest coexistent). Elle reçoit son propre espace de + noms `sys.modules` et ne désinscrit pas le plugin du registre à son + unload — sinon décharger UNE instance cassait les imports paresseux des + instances sœurs encore actives et retirait du registre un plugin pourtant + toujours actif. + """ self._ctx = ctx self.manifest = manifest + self._module_name = f"xcore_plugin_{manifest.name}" + if pooled: + self._module_name += f"__i{next(_POOLED_INSTANCE_IDS)}" + self._manages_registry = not pooled self._services = ctx.services.as_dict() if ctx.services else {} self._events = ctx.events self._hooks = ctx.hooks @@ -155,7 +249,7 @@ async def _do_load(self) -> None: if not entry.exists(): raise LoadError(f"Not found entry point: {entry}") - module_name = f"xcore_plugin_{self.manifest.name}" + module_name = self._module_name package_name = module_name # Crée un package namespace virtuel pour isoler le plugin @@ -413,7 +507,7 @@ async def _do_unload(self, *, run_hooks: bool = True) -> None: await self._cancel_spawned_tasks() - if self._registry is not None: + if self._registry is not None and self._manages_registry: self._registry.unregister(self.manifest.name) # `unregister()` ne nettoie que le registre : les services que le plugin @@ -432,7 +526,7 @@ async def _do_unload(self, *, run_hooks: bool = True) -> None: self.plugin_router = None self.plugin_middlewares = {} - module_name = f"xcore_plugin_{self.manifest.name}" + module_name = self._module_name # Nettoie le module principal et le package namespace sys.modules.pop(f"{module_name}.main", None) sys.modules.pop(module_name, None) @@ -440,8 +534,19 @@ async def _do_unload(self, *, run_hooks: bool = True) -> None: for mod_name in list(sys.modules.keys()): if mod_name.startswith(f"{module_name}."): sys.modules.pop(mod_name, None) - self._instance = None + instance, self._instance = self._instance, None self._module = None + if instance is not None and self._should_watch_release(): + _release_watcher.watch(instance, self.manifest.name, _GC_DELAY_S) + del instance + + def _should_watch_release(self) -> bool: + # Les instances poolées (Ephemeral) sont jetées à chaque appel : le GC + # automatique les gère (jeunes cycles) et une collecte complète par appel + # coûterait bien plus qu'elle ne rapporte. + if not self._manages_registry: + return False + return getattr(self._ctx.config, "gc_after_unload", True) is not False # ── Router HTTP custom ──────────────────────────────────── diff --git a/xcore/kernel/runtime/supervisor.py b/xcore/kernel/runtime/supervisor.py index c76051a..2979400 100644 --- a/xcore/kernel/runtime/supervisor.py +++ b/xcore/kernel/runtime/supervisor.py @@ -148,6 +148,13 @@ async def boot(self) -> None: # ── 5. Enregistrement services noyau (inchangé) ───────── if self._registry: for name, svc in self._services.as_dict().items(): + # Les plugins viennent de propager leurs services exportés dans + # ce même dict : ils restent la propriété du plugin. Les marquer + # « kernel » (protégés) empêchait ensuite le reload du plugin qui + # les exporte (« Impossible d'écraser le service protégé »). + owner = self._registry.service_owner(name) + if owner not in (None, "kernel"): + continue try: self._registry.register_core_service(name, svc) except Exception as e: diff --git a/xcore/kernel/runtime/warm_pool.py b/xcore/kernel/runtime/warm_pool.py index 7af1cdd..aac9852 100644 --- a/xcore/kernel/runtime/warm_pool.py +++ b/xcore/kernel/runtime/warm_pool.py @@ -259,6 +259,7 @@ async def _cold_boot(self) -> "LifecycleManager": manifest=self._manifest, ctx=self._ctx, caller=self._caller, + pooled=True, ) try: await asyncio.wait_for(lm.load(), timeout=self._boot_timeout) diff --git a/xcore/registry/index.py b/xcore/registry/index.py index 581d3dd..448b16b 100644 --- a/xcore/registry/index.py +++ b/xcore/registry/index.py @@ -135,6 +135,11 @@ def list_services(self) -> list[dict]: for name, meta in self._exported_services.items() ] + def service_owner(self, service_name: str) -> str | None: + """Nom du propriétaire d'un service exporté ("kernel" ou un plugin), ou None.""" + existing = self._exported_services.get(service_name) + return None if existing is None else existing.get("plugin") + def is_core_service(self, service_name: str) -> bool: """Vrai si `service_name` est un service noyau (enregistré par le kernel via register_core_service) et non un service exporté par un plugin.""" From bc0819f607272c3d241f42b4b1f9c2b86697e61d Mon Sep 17 00:00:00 2001 From: traoreera Date: Thu, 1 Oct 2026 22:19:25 +0000 Subject: [PATCH 13/15] =?UTF-8?q?fix(sandbox):=20budget=20CPU=20par=20requ?= =?UTF-8?q?=C3=AAte=20+=20recyclage=20du=20worker,=20gel=20du=20event=20lo?= =?UTF-8?q?op=20signal=C3=A9=20(v2.6.8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RLIMIT_CPU est cumulatif : posée une fois à 10 s elle tuait (SIGXCPU) tout worker sain après 10 s de CPU au total, puis le plugin passait FAILED. Limite souple = budget par requête, réarmée avant chaque appel ; limite dure = plafond de vie du worker (max_cpu_lifetime_seconds, non relevable par le plugin) ; le worker se recycle proprement (exit 75) avant de l'atteindre, sans compter un plantage. Vérifié de bout en bout avec le vrai worker - le compteur de redémarrages repart de zéro après un run stable (restart_reset_after) : un plugin qui plante rarement ne finit plus FAILED - watch_blocking : un pas synchrone d'un plugin Trusted, d'un job du scheduler ou d'un hook synchrone en timeout qui gèle le event loop est signalé (plugins.loop_block_warn_ms, status().max_loop_block_ms) ; wait_for ne peut pas interrompre du code synchrone - docs : guide Trusted (blocage, reload/mémoire, limite reload multi-workers), CLAUDE.md resynchronisé (version + pièges 2.6.4–2.6.8) --- CHANGELOG.md | 22 ++ CLAUDE.md | 9 +- doc/changelog.md | 22 ++ doc/plugins/trusted-plugins.md | 27 +- pyproject.toml | 2 +- tests/unit/kernel/test_blocking_sources.py | 103 ++++++++ tests/unit/kernel/test_loop_block_watch.py | 251 +++++++++++++++++++ tests/unit/kernel/test_sandbox_cpu_budget.py | 172 +++++++++++++ xcore/__version__.py | 4 +- xcore/configurations/loader.py | 1 + xcore/configurations/sections.py | 3 + xcore/kernel/events/hooks.py | 18 ++ xcore/kernel/observability/blocking.py | 85 +++++++ xcore/kernel/runtime/lifecycle.py | 40 ++- xcore/kernel/sandbox/isolation.py | 5 + xcore/kernel/sandbox/process_manager.py | 30 ++- xcore/kernel/sandbox/worker.py | 101 +++++++- xcore/services/scheduler/service.py | 36 ++- 18 files changed, 907 insertions(+), 24 deletions(-) create mode 100644 tests/unit/kernel/test_blocking_sources.py create mode 100644 tests/unit/kernel/test_loop_block_watch.py create mode 100644 tests/unit/kernel/test_sandbox_cpu_budget.py create mode 100644 xcore/kernel/observability/blocking.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 45191ba..f8a4812 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.6.8] - 2026-10-01 + +### Fixed +- **A healthy sandboxed worker was killed after 10 cumulative CPU-seconds, then the plugin went `FAILED`**: `RLIMIT_CPU` counts the process's *total* CPU time since it started, but the worker set it once at startup (soft 10 s / hard 15 s, from a hardcoded `_SANDBOX_MAX_CPU_SEC=10`). The kernel's default `SIGXCPU` action terminates the process, and no handler exists (`signal` is forbidden to plugins) — so any plugin doing real work was killed after a few minutes of ordinary requests, restarted, killed again, and marked `FAILED` after `max_restarts` (3). Since `sandboxed` is the default execution mode (2.6.2), this hit every plugin that doesn't declare `execution_mode`. The limit is now two-level: + - **soft limit = CPU budget per request** (`SandboxConfig.max_cpu_seconds`, default 10): the worker re-arms it to "CPU already consumed + budget" before every call (`_arm_cpu_budget`, `resource` is imported before the import guards go up). A request that burns more than its budget is still killed by `SIGXCPU`. + - **hard limit = lifetime ceiling of a worker generation** (`SandboxConfig.max_cpu_lifetime_seconds`, default 3600, +5 s grace). A hard limit can never be raised without privilege, so it stays a kernel-enforced backstop a plugin cannot lift. Before reaching it the worker **recycles itself** between two requests (exit code `RECYCLE_EXIT_CODE`, 75) instead of being killed mid-request; `SandboxProcessManager` restarts it without counting a crash. + Verified end-to-end with the real worker process: 8 requests of 0.5 s CPU each under a 1 s per-request budget all succeed (4 s cumulative — the old behavior killed the worker after the first couple); with a 4 s lifetime ceiling the worker exits with code 75 after 3 requests, not `-24` (`SIGXCPU`). + **Behavior change to be aware of**: the kernel-enforced CPU backstop moves from 15 CPU-seconds per worker to 3600 per worker generation. Runaway requests are still bounded by the 10 s per-request soft limit and by the manager's wall-clock IPC timeout (`resources.timeout_seconds`, which kills and restarts a stuck worker); both settings are configurable. +- **A sandboxed plugin that crashed rarely still ended up `FAILED` for good**: `SandboxProcessManager._restarts` was only reset in `start()`, so three crashes spread over weeks exhausted `max_restarts`. If the subprocess ran at least `SandboxConfig.restart_reset_after` (60 s) before crashing, the restart sequence now starts over; a crash loop is still capped. + +### Added +- **The event loop being frozen by synchronous plugin code is now visible and attributable.** A Trusted plugin runs on the application's single event-loop thread: CPU work, `time.sleep()` or a blocking call between two `await`s suspends every request, `asyncio.wait_for(timeout=...)` cannot interrupt it (measured: a `handle()` burning 1 s of CPU with `timeout_seconds: 0.2` returned `status: ok` after 1000 ms), and under the GIL threads do not help CPU-bound work. `LifecycleManager.call()` now times each synchronous *step* of `handle()` (`xcore.kernel.observability.blocking.watch_blocking`, transparent to results, exceptions, cancellation and timeouts) and logs `plugin blocked the event loop` with the plugin, the action and the blocked time when a step exceeds `plugins.loop_block_warn_ms` (default `250`, `0` disables; one warning per plugin per 10 s). `status()` gains `max_loop_block_ms`. +- Same detection for scheduler jobs (`scheduler job blocked the event loop` — a synchronous job runs entirely on the loop) and for synchronous hooks that exceed their `timeout` (`sync hook timed out, its worker thread keeps running`: `wait_for` stops waiting, not the thread). +- `SandboxConfig.max_cpu_seconds`, `max_cpu_lifetime_seconds`, `restart_reset_after`; `plugins.loop_block_warn_ms`. + +### Documentation +- `doc/plugins/trusted-plugins.md`: why `timeout_seconds` cannot interrupt synchronous code and what the new warning means; a "Reload, Unload and Memory" section (`ctx.spawn_task` vs bare `asyncio.create_task`, what the kernel releases, `plugin instance still referenced after unload`, namespaced scheduler job ids); and the known limitation that **a reload only affects the server worker that handled the request** — there is no cross-worker reload broadcast yet. +- `CLAUDE.md`: version synced (it still said 2.5.3) and the plugin lifecycle/reload pitfalls fixed in 2.6.4–2.6.8 recorded. + +### Not changed (deliberately) +- Synchronous scheduler jobs are **not** moved to `asyncio.to_thread` here: it would silently change the threading model for existing jobs (no running loop in the thread, non-thread-safe state). They are reported instead; moving them is a decision for a minor release. + ## [2.6.7] - 2026-10-01 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index b8c3fbd..3c17b78 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Présentation -**xcore v2.5.3** — framework d'orchestration plugin-first construit sur FastAPI. +**xcore v2.6.8** — framework d'orchestration plugin-first construit sur FastAPI. Charge, isole et gère des plugins modulaires dans un environnement sandboxé. - **Language** : Python 3.12+ @@ -240,3 +240,10 @@ Le cache `.venv` est clé sur `poetry.lock` — un changement de dépendances in - **Branche principale** : `main`. Ne pas confondre avec les branches de feature (`add-ephemeral`, etc.). - **Seuil de coverage** : `fail_under = 80` dans `pyproject.toml` (`branch = true`). Le CI échoue en dessous. Patcher les imports locaux dans `boot()` au niveau du module source (`xcore.services.container.ServiceContainer`) et non au niveau `xcore`. - **`asyncio_mode = auto`** dans `pyproject.toml` — pas besoin de `@pytest.mark.asyncio` sur chaque test async. +- **Tâches de fond d'un plugin** — `ctx.spawn_task()`, jamais `asyncio.create_task()` brut : seules les tâches suivies par le kernel sont annulées *et attendues* au unload/reload. Une tâche brute épingle l'ancienne génération du plugin (visible dans les logs : `plugin instance still referenced after unload`). +- **`self._services` d'un plugin est le dict partagé du `ServiceContainer`** (`LifecycleManager._services`) : n'y écrire que des services *exportés* par le plugin. `propagate_services()` ignore ce qui est identique à l'objet injecté (`_injected_services`) — ne jamais réintroduire un `update()` brut des services injectés (proxy de ramasse-miette, wrappers tenant) dans ce dict : ça casse le reload/load de tous les plugins. +- **`get_service()` des services noyau** — `PluginContext.get_service()` sert les services noyau (`PluginRegistry.is_core_service`) depuis `ctx.services` du plugin, pas depuis le registre (qui contient les objets bruts après le boot : le suivi des jobs et l'isolation tenant seraient contournés). +- **Plugins Ephemeral** — chaque instance poolée est un `LifecycleManager(..., pooled=True)` : espace de noms `sys.modules` propre, pas de désinscription du registre à l'unload. +- **Routes de plugin** — monter/retirer via `Xcore._mount_plugin_router` / `_unmount_plugin_router` (suivi des objets route) ; `app.routes` n'a pas de setter et les `_IncludedRouter` de FastAPI ≥ 0.14x n'ont pas de `.path`. +- **Budget CPU du sandbox** — `RLIMIT_CPU` est cumulatif : limite souple réarmée par requête dans `worker.py` (`_arm_cpu_budget`), limite dure = plafond de vie du worker (`max_cpu_lifetime_seconds`), recyclage propre via `RECYCLE_EXIT_CODE`. Ne pas reposer une limite unique au démarrage. +- **Tests d'intégration du reload** — utiliser un vrai `PluginRegistry` + `ServiceContainer` (+ `register_core_service` pour simuler « après boot »), pas des `MagicMock` : les mocks avaient masqué ces bugs. diff --git a/doc/changelog.md b/doc/changelog.md index 45191ba..f8a4812 100644 --- a/doc/changelog.md +++ b/doc/changelog.md @@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.6.8] - 2026-10-01 + +### Fixed +- **A healthy sandboxed worker was killed after 10 cumulative CPU-seconds, then the plugin went `FAILED`**: `RLIMIT_CPU` counts the process's *total* CPU time since it started, but the worker set it once at startup (soft 10 s / hard 15 s, from a hardcoded `_SANDBOX_MAX_CPU_SEC=10`). The kernel's default `SIGXCPU` action terminates the process, and no handler exists (`signal` is forbidden to plugins) — so any plugin doing real work was killed after a few minutes of ordinary requests, restarted, killed again, and marked `FAILED` after `max_restarts` (3). Since `sandboxed` is the default execution mode (2.6.2), this hit every plugin that doesn't declare `execution_mode`. The limit is now two-level: + - **soft limit = CPU budget per request** (`SandboxConfig.max_cpu_seconds`, default 10): the worker re-arms it to "CPU already consumed + budget" before every call (`_arm_cpu_budget`, `resource` is imported before the import guards go up). A request that burns more than its budget is still killed by `SIGXCPU`. + - **hard limit = lifetime ceiling of a worker generation** (`SandboxConfig.max_cpu_lifetime_seconds`, default 3600, +5 s grace). A hard limit can never be raised without privilege, so it stays a kernel-enforced backstop a plugin cannot lift. Before reaching it the worker **recycles itself** between two requests (exit code `RECYCLE_EXIT_CODE`, 75) instead of being killed mid-request; `SandboxProcessManager` restarts it without counting a crash. + Verified end-to-end with the real worker process: 8 requests of 0.5 s CPU each under a 1 s per-request budget all succeed (4 s cumulative — the old behavior killed the worker after the first couple); with a 4 s lifetime ceiling the worker exits with code 75 after 3 requests, not `-24` (`SIGXCPU`). + **Behavior change to be aware of**: the kernel-enforced CPU backstop moves from 15 CPU-seconds per worker to 3600 per worker generation. Runaway requests are still bounded by the 10 s per-request soft limit and by the manager's wall-clock IPC timeout (`resources.timeout_seconds`, which kills and restarts a stuck worker); both settings are configurable. +- **A sandboxed plugin that crashed rarely still ended up `FAILED` for good**: `SandboxProcessManager._restarts` was only reset in `start()`, so three crashes spread over weeks exhausted `max_restarts`. If the subprocess ran at least `SandboxConfig.restart_reset_after` (60 s) before crashing, the restart sequence now starts over; a crash loop is still capped. + +### Added +- **The event loop being frozen by synchronous plugin code is now visible and attributable.** A Trusted plugin runs on the application's single event-loop thread: CPU work, `time.sleep()` or a blocking call between two `await`s suspends every request, `asyncio.wait_for(timeout=...)` cannot interrupt it (measured: a `handle()` burning 1 s of CPU with `timeout_seconds: 0.2` returned `status: ok` after 1000 ms), and under the GIL threads do not help CPU-bound work. `LifecycleManager.call()` now times each synchronous *step* of `handle()` (`xcore.kernel.observability.blocking.watch_blocking`, transparent to results, exceptions, cancellation and timeouts) and logs `plugin blocked the event loop` with the plugin, the action and the blocked time when a step exceeds `plugins.loop_block_warn_ms` (default `250`, `0` disables; one warning per plugin per 10 s). `status()` gains `max_loop_block_ms`. +- Same detection for scheduler jobs (`scheduler job blocked the event loop` — a synchronous job runs entirely on the loop) and for synchronous hooks that exceed their `timeout` (`sync hook timed out, its worker thread keeps running`: `wait_for` stops waiting, not the thread). +- `SandboxConfig.max_cpu_seconds`, `max_cpu_lifetime_seconds`, `restart_reset_after`; `plugins.loop_block_warn_ms`. + +### Documentation +- `doc/plugins/trusted-plugins.md`: why `timeout_seconds` cannot interrupt synchronous code and what the new warning means; a "Reload, Unload and Memory" section (`ctx.spawn_task` vs bare `asyncio.create_task`, what the kernel releases, `plugin instance still referenced after unload`, namespaced scheduler job ids); and the known limitation that **a reload only affects the server worker that handled the request** — there is no cross-worker reload broadcast yet. +- `CLAUDE.md`: version synced (it still said 2.5.3) and the plugin lifecycle/reload pitfalls fixed in 2.6.4–2.6.8 recorded. + +### Not changed (deliberately) +- Synchronous scheduler jobs are **not** moved to `asyncio.to_thread` here: it would silently change the threading model for existing jobs (no running loop in the thread, non-thread-safe state). They are reported instead; moving them is a decision for a minor release. + ## [2.6.7] - 2026-10-01 ### Fixed diff --git a/doc/plugins/trusted-plugins.md b/doc/plugins/trusted-plugins.md index bac412b..ae89d4a 100644 --- a/doc/plugins/trusted-plugins.md +++ b/doc/plugins/trusted-plugins.md @@ -134,6 +134,7 @@ class Plugin(TrustedBase): | `get_service(name)` | Returns a service from the container. Supports literal overloads for IDE typing. | | `get_service_as(name, type)` | Returns a service cast to a specific type (e.g., `AsyncSQLAdapter`). | | `call_plugin(name, action, payload)` | IPC helper to call another plugin from within the Trusted environment. | +| `ctx.spawn_task(coro, name=None)` | Creates a background task tracked by the kernel: it is cancelled **and awaited** when the plugin is unloaded or reloaded. Use it instead of a bare `asyncio.create_task()`. | --- @@ -156,8 +157,30 @@ entry_point: "src/main.py" **Fix**: Prefix your service names (e.g., `myplugin_db`) or use the `PluginRegistry` to set them as private. !!! warning "Synchronous Blocking" - Trusted plugins run in the main event loop. If you perform blocking I/O (like `time.sleep()` or synchronous requests) inside a hook or `handle`, you will freeze the entire application. - **Fix**: Always use `async`/`await` or `run_in_executor`. + Trusted plugins run in the main event loop, in a single thread. Anything your code does *between two `await`s* — CPU work, `time.sleep()`, a synchronous HTTP or database call — freezes the **entire application**, and the Python GIL means threads do not change that for CPU-bound work. + **Fix**: use `async`/`await`; for blocking I/O use `asyncio.to_thread()`; for heavy CPU work use `execution_mode: sandboxed` (one process per plugin, outside the main loop and its GIL). + +!!! warning "`timeout_seconds` cannot interrupt synchronous code" + The `resources.timeout_seconds` limit is enforced with `asyncio.wait_for`, which can only act at an `await`. A `handle()` that burns 1 s of CPU without awaiting and has `timeout_seconds: 0.2` still returns normally after 1 s. + Xcore now **reports** it instead: a synchronous step longer than `plugins.loop_block_warn_ms` (default `250`, `0` disables) logs `plugin blocked the event loop` with the plugin and action, rate-limited to one warning per plugin every 10 s, and `status()` exposes `max_loop_block_ms`. The same warning exists for scheduler jobs (`scheduler job blocked the event loop`) and for synchronous hooks that exceed their timeout (their worker thread cannot be interrupted and keeps running). + +--- + +### Reload, Unload and Memory + +When a plugin is unloaded or reloaded, the kernel releases what it can track: scheduler jobs, health checks, event/hook subscriptions, services you exported, your router and middlewares, and tasks created with `ctx.spawn_task()`. A plugin instance lives in reference cycles, so Xcore also schedules a garbage collection shortly afterwards (`plugins.gc_after_unload`, default `true`) and checks that the old instance is really gone. + +If you see `plugin instance still referenced after unload` in the logs, something outside the plugin context still holds it — typically: + +- a task started with a bare `asyncio.create_task()` (use `ctx.spawn_task()`), +- a callback or bound method registered on a global object you imported yourself, +- a service object stored somewhere that outlives the plugin. + +!!! note "Scheduler job ids are namespaced" + Jobs you register are stored as `:` so two plugins can both have a `cleanup` job. Inside your plugin you keep using your own id (`remove_job("cleanup")`). + +!!! warning "Reload only affects one worker process" + With several server workers (`uvicorn --workers N`, `xcli manager start --workers N`) each worker process loads its own copy of every plugin. A `POST /plugins//reload` is handled by **one** worker only; the others keep running the old code until restarted. There is no cross-worker reload broadcast yet: restart the workers (or reload through each of them) to roll out a new plugin version. --- diff --git a/pyproject.toml b/pyproject.toml index 1113034..792d9a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "XCoreRuntime" -version = "2.6.7" +version = "2.6.8" description = "Plugin-first orchestration framework built on FastAPI" authors = [ { name = "Eliezer Traore", email = "68350805+traoreera@users.noreply.github.com" }, diff --git a/tests/unit/kernel/test_blocking_sources.py b/tests/unit/kernel/test_blocking_sources.py new file mode 100644 index 0000000..51112e2 --- /dev/null +++ b/tests/unit/kernel/test_blocking_sources.py @@ -0,0 +1,103 @@ +""" +Les autres sources de code synchrone dans le event loop sont signalées elles +aussi : jobs du scheduler et hooks synchrones qui dépassent leur timeout. +""" + +import asyncio +import logging +import threading +import time + +from xcore.kernel.events.hooks import HookManager +from xcore.services.scheduler import service as scheduler_module + + +def _spin(seconds: float) -> None: + end = time.perf_counter() + seconds + while time.perf_counter() < end: + pass + + +class TestSchedulerJobs: + async def test_sync_job_blocking_the_loop_is_reported(self, caplog): + scheduler_module._JOB_REGISTRY["sync_job"] = lambda: _spin(0.06) + try: + scheduler_module._BLOCK_WARN_MS, old = 30, scheduler_module._BLOCK_WARN_MS + with caplog.at_level(logging.WARNING): + await scheduler_module._dispatch_job("sync_job") + finally: + scheduler_module._BLOCK_WARN_MS = old + scheduler_module._JOB_REGISTRY.pop("sync_job", None) + + assert any( + "scheduler job blocked the event loop" in r.getMessage() + for r in caplog.records + ) + + async def test_blocking_step_inside_an_async_job_is_reported(self, caplog): + async def job(): + await asyncio.sleep(0) + _spin(0.06) + + scheduler_module._JOB_REGISTRY["async_job"] = job + try: + scheduler_module._BLOCK_WARN_MS, old = 30, scheduler_module._BLOCK_WARN_MS + with caplog.at_level(logging.WARNING): + await scheduler_module._dispatch_job("async_job") + finally: + scheduler_module._BLOCK_WARN_MS = old + scheduler_module._JOB_REGISTRY.pop("async_job", None) + + assert any("blocked the event loop" in r.getMessage() for r in caplog.records) + + async def test_cooperative_async_job_is_silent_and_runs(self, caplog): + ran = [] + + async def job(): + await asyncio.sleep(0.001) + ran.append(1) + + scheduler_module._JOB_REGISTRY["quiet_job"] = job + try: + with caplog.at_level(logging.WARNING): + await scheduler_module._dispatch_job("quiet_job") + finally: + scheduler_module._JOB_REGISTRY.pop("quiet_job", None) + + assert ran == [1] + assert not [r for r in caplog.records if "blocked" in r.getMessage()] + + +class TestSyncHookTimeout: + async def test_sync_hook_timeout_warns_that_the_thread_keeps_running(self, caplog): + release = threading.Event() + + def slow_hook(event): + release.wait(2) + + hooks = HookManager() + hooks.register("evt", slow_hook, timeout=0.05) + + try: + with caplog.at_level(logging.WARNING): + results = await hooks.emit("evt") + finally: + release.set() + + assert results and results[0].error is not None + assert any( + "worker thread keeps running" in r.getMessage() for r in caplog.records + ) + + async def test_async_hook_timeout_does_not_emit_the_thread_warning(self, caplog): + async def slow_hook(event): + await asyncio.sleep(1) + + hooks = HookManager() + hooks.register("evt", slow_hook, timeout=0.05) + + with caplog.at_level(logging.WARNING): + results = await hooks.emit("evt") + + assert results[0].error is not None + assert not [r for r in caplog.records if "worker thread" in r.getMessage()] diff --git a/tests/unit/kernel/test_loop_block_watch.py b/tests/unit/kernel/test_loop_block_watch.py new file mode 100644 index 0000000..4bd55b2 --- /dev/null +++ b/tests/unit/kernel/test_loop_block_watch.py @@ -0,0 +1,251 @@ +""" +Détection du code synchrone qui gèle le event loop (`watch_blocking`) et son +branchement dans `LifecycleManager.call` pour les plugins Trusted. +""" + +import asyncio +import logging +import time +from types import SimpleNamespace + +import pytest + +from xcore.configurations.sections import ServicesConfig +from xcore.kernel.context import KernelContext +from xcore.kernel.events.bus import EventBus +from xcore.kernel.events.hooks import HookManager +from xcore.kernel.observability.blocking import watch_blocking +from xcore.kernel.observability.health import HealthChecker +from xcore.kernel.runtime.lifecycle import LifecycleManager +from xcore.registry.index import PluginRegistry +from xcore.services.container import ServiceContainer + + +def _spin(seconds: float) -> None: + end = time.perf_counter() + seconds + while time.perf_counter() < end: + pass + + +class TestWatchBlocking: + async def test_reports_a_synchronous_step(self): + seen = [] + + async def work(): + _spin(0.06) # 60 ms sans await + await asyncio.sleep(0) + return "done" + + result = await watch_blocking(work(), 30, seen.append) + + assert result == "done" + assert len(seen) == 1 and seen[0] >= 0.05 + + async def test_cooperative_code_is_not_reported(self): + seen = [] + + async def work(): + for _ in range(20): + await asyncio.sleep(0.001) + return 1 + + assert await watch_blocking(work(), 30, seen.append) == 1 + assert seen == [] + + async def test_each_blocking_step_is_reported_separately(self): + seen = [] + + async def work(): + _spin(0.04) + await asyncio.sleep(0) + _spin(0.04) + + await watch_blocking(work(), 30, seen.append) + + assert len(seen) == 2 + + async def test_exceptions_propagate_unchanged(self): + async def work(): + await asyncio.sleep(0) + raise ValueError("boom") + + with pytest.raises(ValueError, match="boom"): + await watch_blocking(work(), 30, lambda s: None) + + async def test_blocking_step_before_an_exception_is_still_reported(self): + seen = [] + + async def work(): + _spin(0.05) + raise ValueError("boom") + + with pytest.raises(ValueError): + await watch_blocking(work(), 30, seen.append) + + assert len(seen) == 1 + + async def test_cancellation_reaches_the_inner_coroutine(self): + cleaned = [] + + async def work(): + try: + await asyncio.sleep(3600) + finally: + cleaned.append(1) + + task = asyncio.create_task(_run(watch_blocking(work(), 30, lambda s: None))) + await asyncio.sleep(0.01) + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await task + assert cleaned == [1] + + async def test_wait_for_timeout_still_works_through_the_wrapper(self): + async def work(): + await asyncio.sleep(10) + + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(watch_blocking(work(), 30, lambda s: None), 0.05) + + async def test_closing_the_iterator_closes_the_inner_coroutine(self): + cleaned = [] + + async def work(): + try: + await asyncio.sleep(10) + finally: + cleaned.append(1) + + it = watch_blocking(work(), 30, lambda s: None).__await__() + next(it) # avance jusqu'au premier await + it.close() + + assert cleaned == [1] + + async def test_a_failing_report_callback_never_breaks_the_call(self): + async def work(): + _spin(0.05) + return "ok" + + def broken(_): + raise RuntimeError("reporter bug") + + assert await watch_blocking(work(), 30, broken) == "ok" + + async def test_disabled_or_non_awaitable_is_returned_untouched(self): + async def work(): + return 1 + + coro = work() + assert watch_blocking(coro, 0, lambda s: None) is coro + assert watch_blocking(42, 30, lambda s: None) == 42 + await coro + + +async def _run(aw): + return await aw + + +PLUGIN_SRC = """ +import asyncio, time +from xcore.kernel.api.contract import TrustedBase + +class Plugin(TrustedBase): + async def handle(self, action, payload): + if action == "spin": + end = time.perf_counter() + 0.12 + while time.perf_counter() < end: + pass + elif action == "sleep": + await asyncio.sleep(1.0) + return {"status": "ok"} +""" + + +def _manager(tmp_path, timeout=10, **config): + plugin_dir = tmp_path / "p" + (plugin_dir / "src").mkdir(parents=True) + (plugin_dir / "src" / "main.py").write_text(PLUGIN_SRC) + manifest = SimpleNamespace( + name="p", + plugin_dir=plugin_dir, + entry_point="src/main.py", + resources=SimpleNamespace(timeout_seconds=timeout), + env={}, + requires=[], + extra={}, + ) + ctx = KernelContext( + config=SimpleNamespace(tenancy=None, **config), + services=ServiceContainer(ServicesConfig()), + registry=PluginRegistry(), + events=EventBus(), + hooks=HookManager(), + health=HealthChecker(), + ) + return LifecycleManager(manifest, ctx) + + +def _block_warnings(caplog): + return [r for r in caplog.records if "blocked the event loop" in r.getMessage()] + + +class TestPluginCallReportsLoopBlocking: + async def test_synchronous_handler_is_reported_with_plugin_and_action( + self, tmp_path, caplog + ): + lm = _manager(tmp_path, loop_block_warn_ms=50) + await lm.load() + + with caplog.at_level(logging.WARNING): + assert (await lm.call("spin", {}))["status"] == "ok" + + assert len(_block_warnings(caplog)) == 1 + assert lm.status()["max_loop_block_ms"] >= 100 + + async def test_cooperative_handler_is_not_reported(self, tmp_path, caplog): + lm = _manager(tmp_path, loop_block_warn_ms=50) + await lm.load() + + with caplog.at_level(logging.WARNING): + await lm.call("fast", {}) + + assert _block_warnings(caplog) == [] + assert lm.status()["max_loop_block_ms"] == 0 + + async def test_warnings_are_rate_limited_per_plugin(self, tmp_path, caplog): + lm = _manager(tmp_path, loop_block_warn_ms=50) + await lm.load() + + with caplog.at_level(logging.WARNING): + await lm.call("spin", {}) + await lm.call("spin", {}) + + assert len(_block_warnings(caplog)) == 1 + + async def test_can_be_disabled(self, tmp_path, caplog): + lm = _manager(tmp_path, loop_block_warn_ms=0) + await lm.load() + + with caplog.at_level(logging.WARNING): + await lm.call("spin", {}) + + assert _block_warnings(caplog) == [] + + async def test_default_threshold_applies_without_config(self, tmp_path, caplog): + lm = _manager(tmp_path) # pas de loop_block_warn_ms → 250 ms : 120 ms passe + await lm.load() + + with caplog.at_level(logging.WARNING): + await lm.call("spin", {}) + + assert _block_warnings(caplog) == [] + + async def test_timeout_still_enforced_for_cooperative_code(self, tmp_path): + lm = _manager(tmp_path, timeout=0.2, loop_block_warn_ms=50) + await lm.load() + + result = await lm.call("sleep", {}) + + assert result["code"] == "timeout" diff --git a/tests/unit/kernel/test_sandbox_cpu_budget.py b/tests/unit/kernel/test_sandbox_cpu_budget.py new file mode 100644 index 0000000..fd53f60 --- /dev/null +++ b/tests/unit/kernel/test_sandbox_cpu_budget.py @@ -0,0 +1,172 @@ +""" +Le budget CPU du worker sandboxed est PAR REQUÊTE, pas cumulatif. + +RLIMIT_CPU compte le temps CPU total du processus : posé une fois à 10 s, il tuait +un worker parfaitement sain (SIGXCPU) après 10 s de CPU cumulées sur toute sa vie, +puis le plugin passait FAILED après `max_restarts` plantages. Ces tests tournent +dans un sous-processus : abaisser RLIMIT_CPU du process pytest le tuerait. +""" + +import os +import signal +import subprocess +import sys +import textwrap +import time + +import pytest + +from xcore.kernel.sandbox.process_manager import ( + ProcessState, + SandboxConfig, + SandboxProcessManager, +) + +pytestmark = pytest.mark.skipif( + sys.platform == "win32", reason="RLIMIT_CPU n'existe pas sous Windows" +) + +SCRIPT = textwrap.dedent(""" + import os, sys, time + os.environ["_SANDBOX_MAX_CPU_SEC"] = "1" + os.environ["_SANDBOX_MAX_CPU_LIFETIME_SEC"] = sys.argv[3] + from xcore.kernel.sandbox import worker + + worker._apply_resource_limits() + + def burn(seconds): + end = time.process_time() + seconds + while time.process_time() < end: + pass + + for _ in range(int(sys.argv[1])): + worker._arm_cpu_budget() # = avant chaque requête + burn(float(sys.argv[2])) + print("survived", worker._needs_recycle()) + """) + + +def _run( + requests: int, burn_s: float, lifetime_s: int = 0 +) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, "-c", SCRIPT, str(requests), str(burn_s), str(lifetime_s)], + capture_output=True, + text=True, + timeout=60, + env={**os.environ, "LOG_LEVEL": "ERROR"}, + ) + + +def test_cpu_cumulated_over_many_requests_does_not_kill_the_worker(): + # 5 requêtes de 0,6 s = 3 s de CPU au total, bien au-delà du budget de 1 s + result = _run(requests=5, burn_s=0.6) + + assert result.returncode == 0, result.stderr + assert "survived False" in result.stdout # pas de plafond de vie configuré + + +def test_a_single_request_over_budget_is_still_killed(): + started = time.monotonic() + result = _run(requests=1, burn_s=30) + + assert result.returncode == -signal.SIGXCPU + assert time.monotonic() - started < 15 + + +def test_worker_asks_to_be_recycled_before_the_lifetime_ceiling_is_hit(): + """ + Plafond de vie 4 s, budget 1 s : après ≥ 2 s de CPU consommées la requête + suivante ne pourrait plus avoir son budget complet (2 + 1 + 1 >= 4) — le + worker doit demander son recyclage plutôt que d'être tué en pleine requête. + """ + result = _run(requests=4, burn_s=0.6, lifetime_s=4) # ≈ 2,4 s de CPU cumulées + + assert result.returncode == 0, result.stderr + assert "survived True" in result.stdout + + +def test_worker_does_not_recycle_while_far_from_the_ceiling(): + result = _run(requests=1, burn_s=0.2, lifetime_s=60) + + assert result.returncode == 0, result.stderr + assert "survived False" in result.stdout + + +def test_recycle_exit_code_is_shared_between_worker_and_parent(): + from xcore.kernel.sandbox import process_manager, worker + + assert worker.RECYCLE_EXIT_CODE == process_manager.RECYCLE_EXIT_CODE == 75 + + +class TestRestartCounterReset: + @pytest.fixture + def manager(self, tmp_path): + from unittest.mock import AsyncMock, MagicMock + + manifest = MagicMock() + manifest.name = "p" + manifest.plugin_dir = tmp_path + manifest.resources.max_disk_mb = 10 + manifest.resources.max_memory_mb = 128 + manifest.resources.timeout_seconds = 5 + manifest.env = {} + manifest.runtime.health_check.enabled = False + config = SandboxConfig( + restart_delay=0.01, max_restarts=2, restart_reset_after=60.0 + ) + mgr = SandboxProcessManager(manifest, MagicMock(), config=config) + mgr._process = MagicMock(returncode=None, wait=AsyncMock(return_value=1)) + mgr._state = ProcessState.RUNNING + mgr._spawn = AsyncMock() + return mgr + + async def test_crash_after_a_long_stable_run_starts_a_fresh_sequence(self, manager): + manager._restarts = 2 # == max_restarts : serait FAILED sans le reset + manager._started_at = time.monotonic() - 3600 + + await manager._handle_crash() + + assert manager.state == ProcessState.RUNNING + assert manager._restarts == 1 + + async def test_crash_loop_is_still_capped(self, manager): + manager._restarts = 2 + manager._started_at = time.monotonic() - 1 # vient de redémarrer + + await manager._handle_crash() + + assert manager.state == ProcessState.FAILED + + def test_cpu_limits_are_configurable(self): + config = SandboxConfig() + assert config.max_cpu_seconds == 10 + assert config.max_cpu_lifetime_seconds == 3600 + assert SandboxConfig(max_cpu_seconds=3).max_cpu_seconds == 3 + + async def test_recycled_worker_is_restarted_without_counting_as_a_crash( + self, manager + ): + from unittest.mock import AsyncMock + + from xcore.kernel.sandbox.isolation import RECYCLE_EXIT_CODE + + manager._process.wait = AsyncMock(return_value=RECYCLE_EXIT_CODE) + manager._restarts = 2 # == max_restarts : un vrai plantage serait fatal + manager._started_at = time.monotonic() - 1 + + await manager._watch_loop() + + assert manager.state == ProcessState.RUNNING + assert manager._restarts == 1 # le redémarrage lui-même, pas l'historique + + async def test_real_crash_exit_code_still_counts(self, manager): + from unittest.mock import AsyncMock + + manager._process.wait = AsyncMock(return_value=-24) # SIGXCPU + manager._restarts = 2 + manager._started_at = time.monotonic() - 1 + + await manager._watch_loop() + + assert manager.state == ProcessState.FAILED diff --git a/xcore/__version__.py b/xcore/__version__.py index 782b400..ae25f6b 100644 --- a/xcore/__version__.py +++ b/xcore/__version__.py @@ -1,4 +1,4 @@ -__version__ = "2.6.7" -__version_info__ = (2, 6, 7) +__version__ = "2.6.8" +__version_info__ = (2, 6, 8) __author__ = "xcore contributors" __license__ = "MIT" diff --git a/xcore/configurations/loader.py b/xcore/configurations/loader.py index 156c5bc..b2a40d4 100644 --- a/xcore/configurations/loader.py +++ b/xcore/configurations/loader.py @@ -198,6 +198,7 @@ def _parse_plugins(d: dict) -> PluginConfig: interval=d.get("interval", 2), entry_point=d.get("entry_point", "src/main.py"), gc_after_unload=d.get("gc_after_unload", True), + loop_block_warn_ms=d.get("loop_block_warn_ms", 250), snapshot=d.get( "snapshot", { diff --git a/xcore/configurations/sections.py b/xcore/configurations/sections.py index 194ec32..161a2bf 100644 --- a/xcore/configurations/sections.py +++ b/xcore/configurations/sections.py @@ -276,6 +276,9 @@ class PluginConfig: # Force une collecte du GC (différée, regroupée) après un unload/reload de # plugin et signale les instances qui survivent — voir _ReleaseWatcher. gc_after_unload: bool = True + # Un plugin Trusted dont un pas synchrone (entre deux `await`) dépasse ce + # seuil en ms est signalé comme gelant le event loop (0 = désactivé). + loop_block_warn_ms: int = 250 snapshot: dict[str, Any] = field( default_factory=lambda: { "extensions": [".log", ".pyc", ".html"], diff --git a/xcore/kernel/events/hooks.py b/xcore/kernel/events/hooks.py index 44cfae2..f449642 100644 --- a/xcore/kernel/events/hooks.py +++ b/xcore/kernel/events/hooks.py @@ -170,6 +170,24 @@ async def _execute_single_hook( result=result, execution_time_ms=(time.time() - start) * 1000, ) + except asyncio.TimeoutError as e: + if not hook_info.is_async: + # wait_for() annule l'attente, pas le thread : un hook synchrone + # qui dépasse son timeout continue de tourner (en tenant le GIL + # par tranches) et occupe un worker du pool par défaut. + logger.warning( + "sync hook timed out, its worker thread keeps running", + hook=hook_name, + event=event.name, + timeout_s=hook_info.timeout, + hint="make the hook async, or have it honour its own deadline", + ) + return HookResult( + hook_name=hook_name, + event_name=event.name, + error=e, + execution_time_ms=(time.time() - start) * 1000, + ) except Exception as e: return HookResult( hook_name=hook_name, diff --git a/xcore/kernel/observability/blocking.py b/xcore/kernel/observability/blocking.py new file mode 100644 index 0000000..c3f22f7 --- /dev/null +++ b/xcore/kernel/observability/blocking.py @@ -0,0 +1,85 @@ +""" +blocking.py — Détection du code synchrone qui gèle le event loop. + +Un plugin Trusted (ou un job du scheduler) s'exécute dans le thread du event loop. +Tout ce qu'il fait *entre deux `await`* — calcul CPU, I/O bloquant, `time.sleep` — +suspend l'application entière, et `asyncio.wait_for(timeout=...)` ne peut rien y +faire : le timer ne passe qu'au prochain `await`. Mesuré : un handler qui consomme +1 s de CPU sans `await` avec `timeout_seconds=0.2` rend `status: ok` au bout de +1000 ms, pas une erreur de timeout au bout de 200 ms. + +`watch_blocking()` ne peut pas l'empêcher (c'est la limite du GIL + d'un loop +unique), mais le rend visible et attribuable : elle mesure chaque *pas* synchrone +de l'awaitable — le temps passé entre deux suspensions — et prévient quand il +dépasse le seuil. Pour du CPU lourd : `asyncio.to_thread`, ou le mode `sandboxed` +(un processus par plugin, donc hors du GIL et du loop principal). +""" + +from __future__ import annotations + +import contextlib +import inspect +import time +from typing import Any, Awaitable, Callable + + +class _StepWatch: + """Awaitable transparent qui chronomètre chaque pas synchrone de l'inner.""" + + __slots__ = ("_aw", "_threshold_s", "_on_block") + + def __init__( + self, + aw: Awaitable[Any], + threshold_s: float, + on_block: Callable[[float], None], + ) -> None: + self._aw = aw + self._threshold_s = threshold_s + self._on_block = on_block + + def _observe(self, start: float) -> None: + elapsed = time.perf_counter() - start + if elapsed >= self._threshold_s: + # Une erreur de rapport ne doit jamais casser l'appel du plugin. + with contextlib.suppress(Exception): + self._on_block(elapsed) + + def __await__(self): + it = self._aw.__await__() + value: Any = None + exc: BaseException | None = None + while True: + start = time.perf_counter() + try: + yielded = it.throw(exc) if exc is not None else it.send(value) + except StopIteration as stop: + self._observe(start) + return stop.value + except BaseException: + self._observe(start) + raise + self._observe(start) + exc, value = None, None + try: + value = yield yielded + except GeneratorExit: + it.close() + raise + except BaseException as e: # CancelledError (timeout/cancel) inclus + exc = e + + +def watch_blocking( + aw: Awaitable[Any], + threshold_ms: float, + on_block: Callable[[float], None], +) -> Awaitable[Any]: + """ + Enveloppe `aw` pour signaler (via `on_block(seconds)`) chaque pas synchrone + d'au moins `threshold_ms`. Sans effet si le seuil est ≤ 0 ou si `aw` n'est + pas un awaitable. + """ + if threshold_ms <= 0 or not inspect.isawaitable(aw): + return aw + return _StepWatch(aw, threshold_ms / 1000.0, on_block) diff --git a/xcore/kernel/runtime/lifecycle.py b/xcore/kernel/runtime/lifecycle.py index fed896e..0bdaa28 100644 --- a/xcore/kernel/runtime/lifecycle.py +++ b/xcore/kernel/runtime/lifecycle.py @@ -27,11 +27,18 @@ from ..api.context import PluginContext from ..api.contract import BasePlugin from ..observability import get_logger +from ..observability.blocking import watch_blocking from .plugin_gc import PluginResourceTracker, _ScopedScheduler from .state_machine import PluginState, StateMachine logger = get_logger("xcore.runtime.lifecycle") +# Seuil (ms) au-delà duquel un pas synchrone d'un plugin Trusted — du code qui +# tourne entre deux `await` — est signalé comme gelant le event loop (0 = off). +_DEFAULT_LOOP_BLOCK_WARN_MS = 250 +# Au plus un avertissement de gel par plugin sur cette fenêtre (secondes). +_LOOP_BLOCK_LOG_INTERVAL_S = 10.0 + # Délai avant la collecte qui suit un unload/reload : laisse les tâches annulées, # les callbacks et les réponses en vol se terminer, et regroupe plusieurs unloads # rapprochés en UNE seule collecte. @@ -180,6 +187,10 @@ def __init__( # retirés au unload pour ne pas laisser un plugin mort appelable. self._exported_to_container: dict[str, Any] = {} + # Gel du event loop par du code synchrone du plugin (voir watch_blocking) + self._max_block_ms: float = 0.0 + self._last_block_log: float = float("-inf") + self._sm = StateMachine( manifest.name, on_change=self._on_state_change, @@ -383,6 +394,28 @@ def _import_module(name: str, path: Path) -> Any: # ── Appel ───────────────────────────────────────────────── + def _loop_block_warn_ms(self) -> float: + value = getattr(self._ctx.config, "loop_block_warn_ms", None) + if isinstance(value, bool) or not isinstance(value, (int, float)): + return _DEFAULT_LOOP_BLOCK_WARN_MS + return value + + def _on_loop_block(self, action: str, seconds: float) -> None: + """Un pas synchrone du plugin vient de geler le event loop `seconds`.""" + self._max_block_ms = max(self._max_block_ms, seconds * 1000) + now = time.monotonic() + if now - self._last_block_log < _LOOP_BLOCK_LOG_INTERVAL_S: + return + self._last_block_log = now + logger.warning( + "plugin blocked the event loop", + plugin=self.manifest.name, + action=action, + blocked_ms=round(seconds * 1000), + hint="synchronous CPU/IO between two awaits; use await, " + "asyncio.to_thread, or execution_mode: sandboxed", + ) + async def call(self, action: str, payload: dict) -> dict: if self._instance is None: raise RuntimeError(f"[{self.manifest.name}] not loaded") @@ -395,7 +428,11 @@ async def call(self, action: str, payload: dict) -> dict: timeout = self.manifest.resources.timeout_seconds try: result = await asyncio.wait_for( - self._instance.handle(action, payload), + watch_blocking( + self._instance.handle(action, payload), + self._loop_block_warn_ms(), + lambda seconds: self._on_loop_block(action, seconds), + ), timeout=timeout if timeout > 0 else None, ) except asyncio.TimeoutError: @@ -756,4 +793,5 @@ def status(self) -> dict: "state": self._sm.state.value, "loaded": self._instance is not None, "uptime": round(self.uptime, 1) if self.uptime else None, + "max_loop_block_ms": round(self._max_block_ms), } diff --git a/xcore/kernel/sandbox/isolation.py b/xcore/kernel/sandbox/isolation.py index d6ab968..a9a519e 100644 --- a/xcore/kernel/sandbox/isolation.py +++ b/xcore/kernel/sandbox/isolation.py @@ -10,6 +10,11 @@ logger = get_logger("xcore.sandbox.isolation") +# Code de sortie d'un worker qui se recycle de lui-même parce que son plafond de +# CPU cumulé est presque atteint (EX_TEMPFAIL) — ce n'est pas un plantage : le +# SandboxProcessManager le relance sans le compter dans `max_restarts`. +RECYCLE_EXIT_CODE = 75 + class DiskQuotaExceeded(Exception): pass diff --git a/xcore/kernel/sandbox/process_manager.py b/xcore/kernel/sandbox/process_manager.py index 1e92b7c..2b0c53b 100644 --- a/xcore/kernel/sandbox/process_manager.py +++ b/xcore/kernel/sandbox/process_manager.py @@ -21,7 +21,7 @@ from ..runtime.loader import PluginLoader from .ipc import IPCChannel, IPCProcessDead, IPCTimeoutError -from .isolation import DiskQuotaExceeded, DiskWatcher +from .isolation import RECYCLE_EXIT_CODE, DiskQuotaExceeded, DiskWatcher logger = get_logger("xcore.sandbox.process_manager") @@ -54,6 +54,16 @@ class SandboxConfig: max_restarts: int = 3 restart_delay: float = 1.0 startup_timeout: float = 5.0 + # Budget CPU du worker PAR REQUÊTE (secondes) — 0 = illimité. + max_cpu_seconds: int = 10 + # Plafond de CPU cumulé de la vie d'un worker (secondes), filet noyau + # infranchissable pour le plugin. Avant de l'atteindre le worker se recycle + # tout seul (RECYCLE_EXIT_CODE) — relance qui ne compte pas comme un plantage. + max_cpu_lifetime_seconds: int = 3600 + # Si le subprocess a tourné au moins aussi longtemps avant de planter, la + # séquence de redémarrages repart de zéro : sans ça, un plugin qui plante une + # fois par semaine finissait FAILED au 3e plantage en 3 semaines. + restart_reset_after: float = 60.0 class SandboxProcessManager: @@ -155,7 +165,8 @@ async def _spawn(self) -> None: "PYTHONDONTWRITEBYTECODE": "1", "PYTHONUNBUFFERED": "1", "_SANDBOX_MAX_MEM_MB": str(self.manifest.resources.max_memory_mb), - "_SANDBOX_MAX_CPU_SEC": "10", + "_SANDBOX_MAX_CPU_SEC": str(self.config.max_cpu_seconds), + "_SANDBOX_MAX_CPU_LIFETIME_SEC": str(self.config.max_cpu_lifetime_seconds), "LOG_LEVEL": self._log_level, } env |= self.manifest.env @@ -215,7 +226,15 @@ async def _watch_loop(self) -> None: code = await self._process.wait() if self._state == ProcessState.STOPPED: return - logger.warning("subprocess exited", plugin=self.manifest.name, exit_code=code) + if code == RECYCLE_EXIT_CODE: + # Le worker a atteint son plafond de CPU cumulé et s'est arrêté de + # lui-même entre deux requêtes : relance normale, pas un plantage. + logger.info("subprocess recycled", plugin=self.manifest.name) + self._restarts = 0 + else: + logger.warning( + "subprocess exited", plugin=self.manifest.name, exit_code=code + ) await self._handle_crash() async def _stderr_pump(self) -> None: @@ -301,6 +320,11 @@ async def _handle_crash(self) -> None: ): return # anti-réentrance + if ( + self._started_at is not None + and time.monotonic() - self._started_at >= self.config.restart_reset_after + ): + self._restarts = 0 self._state = ProcessState.RESTARTING while self._restarts < self.config.max_restarts: diff --git a/xcore/kernel/sandbox/worker.py b/xcore/kernel/sandbox/worker.py index 96366a0..23e443b 100644 --- a/xcore/kernel/sandbox/worker.py +++ b/xcore/kernel/sandbox/worker.py @@ -24,6 +24,7 @@ from xcore.kernel.observability import get_logger from xcore.kernel.observability.logging import _TextFormatter +from xcore.kernel.sandbox.isolation import RECYCLE_EXIT_CODE # ContextVar par tâche asyncio — évite les race conditions entre coroutines # qui partageraient le même FilesystemGuard (requis pour la sécurité sandbox). @@ -64,19 +65,89 @@ def _apply_resource_limits() -> None: logger.debug("memory limit applied", max_mb=max_mb, resource="DATA+RSS") # ── CPU ─────────────────────────────────────────────────────────── + # RLIMIT_CPU compte le temps CPU CUMULÉ du processus depuis son + # démarrage : posée une fois à 10 s, elle tuait (SIGXCPU — action par + # défaut : terminer) tout worker sain après 10 s de CPU au total sur sa + # vie, soit quelques minutes de requêtes ordinaires, puis le plugin + # finissait FAILED après `max_restarts`. Deux niveaux à la place : + # - limite SOUPLE = budget PAR REQUÊTE, réarmée avant chaque appel à + # « CPU déjà consommé + budget » (_arm_cpu_budget) ; + # - limite DURE = plafond de CPU cumulé de la vie du worker (+ grace). + # Une limite dure ne peut jamais être relevée sans privilège, donc + # elle reste un filet côté noyau qu'un plugin ne peut pas contourner. + # Avant de l'atteindre le worker se recycle proprement + # (_needs_recycle → RECYCLE_EXIT_CODE) au lieu d'être tué. + global _cpu_budget_s, _cpu_lifetime_s max_cpu_s = int(os.environ.get("_SANDBOX_MAX_CPU_SEC", "0")) if max_cpu_s > 0: - # soft = envoi SIGXCPU quand la limite est atteinte (attrapable) - # hard = SIGKILL irrécupérable, fixé légèrement au-dessus - soft = max_cpu_s - hard = max_cpu_s + 5 # 5s de grâce pour un éventuel cleanup - resource.setrlimit(resource.RLIMIT_CPU, (soft, hard)) - logger.debug("cpu limit applied", soft_s=soft, hard_s=hard) + _cpu_budget_s = max_cpu_s + _cpu_lifetime_s = int(os.environ.get("_SANDBOX_MAX_CPU_LIFETIME_SEC", "0")) + hard = _cpu_lifetime_s + 5 if _cpu_lifetime_s > 0 else None + _arm_cpu_budget(hard) + logger.debug( + "cpu limits applied", + per_request_s=_cpu_budget_s, + lifetime_s=_cpu_lifetime_s, + ) except Exception as e: logger.warning("failed to apply resource limits", error=str(e)) +# Budget CPU par requête et plafond cumulé du worker (secondes), 0 = illimité — +# voir _apply_resource_limits. +_cpu_budget_s: int = 0 +_cpu_lifetime_s: int = 0 + +# Importé ici, AVANT l'installation des gardes d'import : `resource` est interdit +# au code du plugin, mais le worker en a besoin à chaque requête. +try: + import resource as _resource +except ImportError: # Windows + _resource = None + + +def _cpu_consumed() -> float: + usage = _resource.getrusage(_resource.RUSAGE_SELF) + return usage.ru_utime + usage.ru_stime + + +def _arm_cpu_budget(hard: int | None = None) -> None: + """ + Réarme la limite SOUPLE de RLIMIT_CPU à « CPU déjà consommé + budget ». + + `hard` n'est passé qu'au premier appel (il fixe la limite dure, irréversible) ; + ensuite on garde celle déjà en place. Au dépassement de la limite souple le + noyau envoie SIGXCPU ; sans handler (`signal` est interdit au plugin) c'est la + fin du processus, que SandboxProcessManager détecte et relance. + """ + if not _cpu_budget_s or _resource is None: + return + try: + soft = int(_cpu_consumed()) + _cpu_budget_s + 1 # +1 : granularité 1 s + if hard is None: + _, hard = _resource.getrlimit(_resource.RLIMIT_CPU) + if hard != _resource.RLIM_INFINITY: + soft = min(soft, hard) + _resource.setrlimit(_resource.RLIMIT_CPU, (soft, hard)) + except Exception as e: + logger.debug("cannot re-arm cpu budget", error=str(e)) + + +def _needs_recycle() -> bool: + """ + Vrai quand la prochaine requête ne pourrait plus avoir son budget complet + sous le plafond cumulé : mieux vaut se recycler (proprement, entre deux + requêtes) que d'être tué en plein milieu de la suivante. + """ + if not _cpu_lifetime_s or not _cpu_budget_s or _resource is None: + return False + try: + return _cpu_consumed() + _cpu_budget_s + 1 >= _cpu_lifetime_s + except Exception: + return False + + builtins_open = _builtins_module.open @@ -792,7 +863,9 @@ def _trace_id_from_carrier(carrier: dict | None) -> str | None: # ───────────────────────────────────────────────────────────────────────────── -async def _run(plugin_dir: Path) -> None: +async def _run(plugin_dir: Path) -> bool: + """Boucle du worker. Retourne True si le worker demande à être recyclé.""" + recycle = False # 1. Lecture du manifeste manifest = _load_manifest(plugin_dir) @@ -870,6 +943,7 @@ def connection_lost(self, exc): _send(transport, response) break else: + _arm_cpu_budget() result = await plugin.handle(action, payload) response = ( result @@ -896,6 +970,15 @@ def connection_lost(self, exc): _send(transport, response) + if _needs_recycle(): + logger.info( + "cpu ceiling nearly reached, recycling worker", + consumed_s=round(_cpu_consumed(), 1), + lifetime_s=_cpu_lifetime_s, + ) + recycle = True + break + if hasattr(plugin, "on_unload"): try: await plugin.on_unload() @@ -906,6 +989,7 @@ def connection_lost(self, exc): plugin._import_hook.uninstall() logger.info("sandbox worker stopped") + return recycle # ───────────────────────────────────────────────────────────────────────────── @@ -923,7 +1007,8 @@ def connection_lost(self, exc): sys.exit(1) try: - asyncio.run(_run(plugin_dir)) + if asyncio.run(_run(plugin_dir)): + sys.exit(RECYCLE_EXIT_CODE) except KeyboardInterrupt: pass except Exception as e: diff --git a/xcore/services/scheduler/service.py b/xcore/services/scheduler/service.py index 94285f1..b05158a 100644 --- a/xcore/services/scheduler/service.py +++ b/xcore/services/scheduler/service.py @@ -47,12 +47,14 @@ async def morning_sync(): from __future__ import annotations import inspect +import time from typing import TYPE_CHECKING, Any, Callable if TYPE_CHECKING: from ...configurations.sections import SchedulerConfig from ...kernel.observability import get_logger +from ...kernel.observability.blocking import watch_blocking from ..base import BaseService, ServiceStatus logger = get_logger("xcore.services.scheduler") @@ -71,6 +73,32 @@ async def morning_sync(): _LOCK_TTL = 300 +# Seuil (ms) au-delà duquel un job qui tourne entre deux `await` est signalé +# comme gelant le event loop — les jobs s'exécutent dans le loop de l'application. +_BLOCK_WARN_MS = 250 + + +def _warn_blocking(job_id: str, seconds: float) -> None: + logger.warning( + "scheduler job blocked the event loop", + job_id=job_id, + blocked_ms=round(seconds * 1000), + hint="a sync job runs on the event loop; make it async or use asyncio.to_thread", + ) + + +async def _run_job(fn: Callable, job_id: str) -> None: + started = time.perf_counter() + result = fn() # un job synchrone s'exécute ENTIÈREMENT ici, sur le loop + elapsed = time.perf_counter() - started + if elapsed * 1000 >= _BLOCK_WARN_MS: + _warn_blocking(job_id, elapsed) + if inspect.isawaitable(result): + await watch_blocking( + result, _BLOCK_WARN_MS, lambda seconds: _warn_blocking(job_id, seconds) + ) + + async def _dispatch_job(job_id: str) -> None: fn = _JOB_REGISTRY.get(job_id) if fn is None: @@ -90,15 +118,11 @@ async def _dispatch_job(job_id: str) -> None: ) return try: - result = fn() - if inspect.isawaitable(result): - await result + await _run_job(fn, job_id) finally: await _REDIS_LOCK_CLIENT.delete(lock_key) else: - result = fn() - if inspect.isawaitable(result): - await result + await _run_job(fn, job_id) class SchedulerService(BaseService): From 4b2e6ce400d8ee4dc9cf087b44ba460dbbec80ba Mon Sep 17 00:00:00 2001 From: traoreera Date: Thu, 1 Oct 2026 22:38:08 +0000 Subject: [PATCH 14/15] =?UTF-8?q?test(sandbox):=20test=20de=20recyclage=20?= =?UTF-8?q?du=20worker=20d=C3=A9terministe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le test lançait 4 requêtes de 0,6 s de CPU en sous-processus et supposait le plafond de vie atteint ; échec observé une fois sur un run complet de la suite sous forte charge. La condition _needs_recycle() est maintenant testée avec une consommation CPU simulée (les tests de survie cumulée et de dépassement par requête, eux, restent de bout en bout). --- tests/unit/kernel/test_sandbox_cpu_budget.py | 24 ++++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/tests/unit/kernel/test_sandbox_cpu_budget.py b/tests/unit/kernel/test_sandbox_cpu_budget.py index fd53f60..2d68f4a 100644 --- a/tests/unit/kernel/test_sandbox_cpu_budget.py +++ b/tests/unit/kernel/test_sandbox_cpu_budget.py @@ -74,16 +74,26 @@ def test_a_single_request_over_budget_is_still_killed(): assert time.monotonic() - started < 15 -def test_worker_asks_to_be_recycled_before_the_lifetime_ceiling_is_hit(): +def test_worker_asks_to_be_recycled_before_the_lifetime_ceiling_is_hit( + monkeypatch, +): """ - Plafond de vie 4 s, budget 1 s : après ≥ 2 s de CPU consommées la requête - suivante ne pourrait plus avoir son budget complet (2 + 1 + 1 >= 4) — le - worker doit demander son recyclage plutôt que d'être tué en pleine requête. + Plafond de vie 4 s, budget 1 s : dès que ≥ 2 s de CPU sont consommées la + requête suivante ne pourrait plus avoir son budget complet (2 + 1 + 1 >= 4) — + le worker doit demander son recyclage plutôt que d'être tué en pleine requête. + Déterministe : la consommation CPU est simulée (aucune limite posée sur pytest). """ - result = _run(requests=4, burn_s=0.6, lifetime_s=4) # ≈ 2,4 s de CPU cumulées + from xcore.kernel.sandbox import worker - assert result.returncode == 0, result.stderr - assert "survived True" in result.stdout + monkeypatch.setattr(worker, "_cpu_budget_s", 1) + monkeypatch.setattr(worker, "_cpu_lifetime_s", 4) + + for consumed, expected in ((0.2, False), (1.9, False), (2.0, True), (3.5, True)): + monkeypatch.setattr(worker, "_cpu_consumed", lambda c=consumed: c) + assert worker._needs_recycle() is expected, consumed + + monkeypatch.setattr(worker, "_cpu_lifetime_s", 0) # aucun plafond configuré + assert worker._needs_recycle() is False def test_worker_does_not_recycle_while_far_from_the_ceiling(): From f04a73db3c53867d83ff1a409d400a1ad2fd4618 Mon Sep 17 00:00:00 2001 From: traoreera Date: Thu, 1 Oct 2026 23:17:18 +0000 Subject: [PATCH 15/15] =?UTF-8?q?fix(sdk):=20import=20xcore=20sans=20SQLAl?= =?UTF-8?q?chemy=20=E2=80=94=20adaptateurs=20SQL=20r=C3=A9solus=20=C3=A0?= =?UTF-8?q?=20la=20demande=20(v2.7.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sur une installation minimale (pip install XCoreRuntime, sans extras), `import xcore` plantait : xcore/sdk/__init__.py importait BaseAsyncRepository / BaseSyncRepository — donc SQLAlchemy — à l'import, et xcore.kernel.security tire xcore.sdk. La CI installe le groupe dev (qui contient encore tous les backends), donc rien ne l'avait vu ; trouvé en construisant le wheel et en l'installant dans un virtualenv vierge avant la release. - les deux adaptateurs SQL sont résolus à la demande (PEP 562) : inchangé avec SQLAlchemy installé, ImportError explicite (XCoreRuntime[db]) sinon ; listés dans __all__ seulement si SQLAlchemy est importable - tests de non-régression simulant l'installation minimale en sous-processus (import, erreur explicite, boot zero-config) - CHANGELOG [2.7.0] (Fixed) + note dans le guide des adaptateurs --- CHANGELOG.md | 4 + doc/changelog.md | 4 + doc/sdk/api/adapters.md | 3 + tests/unit/test_optional_dependencies.py | 133 +++++++++++++++++++++++ xcore/sdk/__init__.py | 50 ++++++++- 5 files changed, 189 insertions(+), 5 deletions(-) create mode 100644 tests/unit/test_optional_dependencies.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 80385d1..360af9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 **If you relied on a bare `pip install XCoreRuntime` for a working database, Redis cache/scheduler, Celery worker, migrations, OTLP export, `.env` loading, or Prometheus metrics, add the matching extra(s).** `apscheduler` stays in core: `SchedulerConfig.enabled` defaults to `True`, so the scheduler runs out of the box (in-memory backend) even with zero configuration — same for `opentelemetry-api`/`-sdk`, imported unconditionally at module load by `kernel/observability/tracing.py`. - **Dependency versions refreshed**: `fastapi[standard]` 0.135→0.141, `pydantic` 2.11→2.13, `sqlalchemy` 2.0→2.1 (now pinned with `[asyncio]` in every DB extra — it was previously relying on `aiosqlite` to pull in `greenlet` transitively, which silently broke a Postgres-only install), `redis[hiredis]` upper bound raised 8→9, `apscheduler` →3.11.3, `opentelemetry-api`/`-sdk`/`-exporter-otlp-proto-http` 1.27→1.45, `alembic` →1.20, `python-dotenv` →1.2, `psycopg2` →2.9.13, `prometheus-client` 0.25→0.26 (dev dependency and new `metrics` extra aligned to the same constraint — `poetry lock` rejects mismatched ones for the same package). All backend extras are duplicated into `[tool.poetry.group.dev.dependencies]` so `poetry install --with dev` (what CI runs, without `--extras`) still exercises every backend in tests. +### Fixed +- **`import xcore` crashed on a plain `pip install XCoreRuntime`**: the extras split above was not exercised against a minimal install (CI installs the dev group, which still carries every backend). `xcore/sdk/__init__.py` imported `BaseAsyncRepository`/`BaseSyncRepository` — and therefore SQLAlchemy — at import time, and `xcore.kernel.security` pulls `xcore.sdk` in, so `import xcore` raised `ModuleNotFoundError: No module named 'sqlalchemy'` and nothing could boot. Found by building the wheel and installing it in a clean virtualenv before release. The two SQL adapters are now resolved on demand (PEP 562 `__getattr__`): `from xcore.sdk import BaseAsyncRepository` behaves exactly as before when SQLAlchemy is installed, and raises an explicit `ImportError` (`pip install 'XCoreRuntime[db]'`) otherwise; they are listed in `xcore.sdk.__all__` only when SQLAlchemy is importable, so `from xcore.sdk import *` never fails for a dependency the user does not need. Verified in a clean venv with only the base dependencies: `import xcore`, zero-config boot, a trusted and a sandboxed plugin loaded and called, and repeated reloads. +- New regression tests (`tests/unit/test_optional_dependencies.py`) simulate that minimal install in a subprocess (`sys.modules[] = None` for SQLAlchemy, Redis, Celery, Alembic, the DB drivers and prometheus-client): `import xcore`, the explicit adapter error, and a zero-config boot. + ## [2.6.8] - 2026-10-01 ### Fixed diff --git a/doc/changelog.md b/doc/changelog.md index 80385d1..360af9b 100644 --- a/doc/changelog.md +++ b/doc/changelog.md @@ -23,6 +23,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 **If you relied on a bare `pip install XCoreRuntime` for a working database, Redis cache/scheduler, Celery worker, migrations, OTLP export, `.env` loading, or Prometheus metrics, add the matching extra(s).** `apscheduler` stays in core: `SchedulerConfig.enabled` defaults to `True`, so the scheduler runs out of the box (in-memory backend) even with zero configuration — same for `opentelemetry-api`/`-sdk`, imported unconditionally at module load by `kernel/observability/tracing.py`. - **Dependency versions refreshed**: `fastapi[standard]` 0.135→0.141, `pydantic` 2.11→2.13, `sqlalchemy` 2.0→2.1 (now pinned with `[asyncio]` in every DB extra — it was previously relying on `aiosqlite` to pull in `greenlet` transitively, which silently broke a Postgres-only install), `redis[hiredis]` upper bound raised 8→9, `apscheduler` →3.11.3, `opentelemetry-api`/`-sdk`/`-exporter-otlp-proto-http` 1.27→1.45, `alembic` →1.20, `python-dotenv` →1.2, `psycopg2` →2.9.13, `prometheus-client` 0.25→0.26 (dev dependency and new `metrics` extra aligned to the same constraint — `poetry lock` rejects mismatched ones for the same package). All backend extras are duplicated into `[tool.poetry.group.dev.dependencies]` so `poetry install --with dev` (what CI runs, without `--extras`) still exercises every backend in tests. +### Fixed +- **`import xcore` crashed on a plain `pip install XCoreRuntime`**: the extras split above was not exercised against a minimal install (CI installs the dev group, which still carries every backend). `xcore/sdk/__init__.py` imported `BaseAsyncRepository`/`BaseSyncRepository` — and therefore SQLAlchemy — at import time, and `xcore.kernel.security` pulls `xcore.sdk` in, so `import xcore` raised `ModuleNotFoundError: No module named 'sqlalchemy'` and nothing could boot. Found by building the wheel and installing it in a clean virtualenv before release. The two SQL adapters are now resolved on demand (PEP 562 `__getattr__`): `from xcore.sdk import BaseAsyncRepository` behaves exactly as before when SQLAlchemy is installed, and raises an explicit `ImportError` (`pip install 'XCoreRuntime[db]'`) otherwise; they are listed in `xcore.sdk.__all__` only when SQLAlchemy is importable, so `from xcore.sdk import *` never fails for a dependency the user does not need. Verified in a clean venv with only the base dependencies: `import xcore`, zero-config boot, a trusted and a sandboxed plugin loaded and called, and repeated reloads. +- New regression tests (`tests/unit/test_optional_dependencies.py`) simulate that minimal install in a subprocess (`sys.modules[] = None` for SQLAlchemy, Redis, Celery, Alembic, the DB drivers and prometheus-client): `import xcore`, the explicit adapter error, and a zero-config boot. + ## [2.6.8] - 2026-10-01 ### Fixed diff --git a/doc/sdk/api/adapters.md b/doc/sdk/api/adapters.md index 90ab67c..3e771d7 100644 --- a/doc/sdk/api/adapters.md +++ b/doc/sdk/api/adapters.md @@ -17,6 +17,9 @@ from xcore.sdk import ( ) ``` +!!! note "SQLAlchemy is an optional dependency" + Since 2.7.0 `BaseAsyncRepository` and `BaseSyncRepository` need SQLAlchemy, which is no longer installed with a plain `pip install XCoreRuntime`. Install the extra that matches your database — `XCoreRuntime[postgres]`, `XCoreRuntime[sqlite]` or `XCoreRuntime[db]` (both). Without it, importing these two names raises an `ImportError` that tells you so; the rest of `xcore.sdk` works unchanged. + --- ## BaseAsyncRepository diff --git a/tests/unit/test_optional_dependencies.py b/tests/unit/test_optional_dependencies.py new file mode 100644 index 0000000..f5241bb --- /dev/null +++ b/tests/unit/test_optional_dependencies.py @@ -0,0 +1,133 @@ +""" +Depuis la 2.7.0 les dépendances propres à un backend (SQLAlchemy, Redis, Celery, +Alembic, drivers DB, prometheus-client) sont des extras optionnels : un simple +`pip install XCoreRuntime` ne les installe pas. `import xcore` et le boot +zero-config doivent donc fonctionner sans elles. + +Ces tests simulent cette installation dans un sous-processus en bloquant les +paquets au niveau de l'import — l'environnement de dev, lui, les a tous. +(Régression : `xcore/sdk/__init__.py` importait SQLAlchemy à l'import, donc +`import xcore` plantait sur une installation minimale.) +""" + +import subprocess +import sys +import textwrap + +# `sys.modules[nom] = None` est l'idiome standard pour « ce paquet n'est pas +# installé » : `import nom` lève ModuleNotFoundError et `importlib.util.find_spec` +# renvoie None — exactement ce que voit le code sur une installation minimale. +BLOCKER = textwrap.dedent(""" + import sys + + for _name in ( + "sqlalchemy", "redis", "celery", "alembic", "aiosqlite", "psycopg2", + "prometheus_client", "motor", "pymongo", + ): + sys.modules[_name] = None + """) + + +def _run(code: str, cwd=None) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, "-c", BLOCKER + textwrap.dedent(code)], + capture_output=True, + text=True, + timeout=120, + cwd=cwd, + ) + + +def test_import_xcore_works_without_optional_backends(): + result = _run(""" + import xcore + import xcore.sdk as sdk + from xcore import Xcore, TrustedBase + from xcore.sdk import TrustedBase as T2, action, ok + + assert "BaseAsyncRepository" not in sdk.__all__ + assert "BaseSyncRepository" not in sdk.__all__ + exec("from xcore.sdk import *") # ne doit pas lever + print("import-ok") + """) + + assert result.returncode == 0, result.stderr + assert "import-ok" in result.stdout + + +def test_sql_adapters_raise_an_explicit_error_when_sqlalchemy_is_missing(): + result = _run(""" + import xcore.sdk as sdk + try: + sdk.BaseAsyncRepository + except ImportError as e: + print("ERR:", e) + else: + raise SystemExit("aurait dû lever ImportError") + try: + from xcore.sdk import BaseSyncRepository + except ImportError as e: + print("ERR2:", e) + try: + sdk.does_not_exist + except AttributeError: + print("attr-ok") + """) + + assert result.returncode == 0, result.stderr + assert "XCoreRuntime[db]" in result.stdout + assert "ERR2:" in result.stdout + assert "attr-ok" in result.stdout + + +def test_zero_config_boot_works_without_optional_backends(tmp_path): + (tmp_path / "plugins").mkdir() + (tmp_path / "integration.yaml").write_text( + "app:\n name: smoke\n env: development\n secret_key: smoke-key\n" + "plugins:\n directory: ./plugins\n strict_trusted: false\n" + ) + + result = _run( + """ + import asyncio + from xcore import Xcore + + async def main(): + x = Xcore("integration.yaml") + await x.boot() + print("booted", x.plugins.list_plugins()) + await x.shutdown() + + asyncio.run(main()) + """, + cwd=tmp_path, + ) + + assert result.returncode == 0, result.stderr + assert "booted ['xcore']" in result.stdout + + +class TestWithSqlAlchemyInstalled: + """Dans l'environnement de dev, rien ne change pour les utilisateurs de l'extra.""" + + def test_adapters_resolve_lazily_and_are_cached(self): + import xcore.sdk as sdk + + assert "BaseAsyncRepository" in sdk.__all__ + assert "BaseSyncRepository" in sdk.__all__ + first = sdk.BaseAsyncRepository + assert first.__name__ == "BaseAsyncRepository" + assert "BaseAsyncRepository" in vars(sdk) # mis en cache + assert sdk.BaseAsyncRepository is first + + from xcore.sdk import BaseSyncRepository + + assert BaseSyncRepository.__name__ == "BaseSyncRepository" + + def test_unknown_attribute_still_raises_attribute_error(self): + import pytest + + import xcore.sdk as sdk + + with pytest.raises(AttributeError): + sdk.definitely_not_there diff --git a/xcore/sdk/__init__.py b/xcore/sdk/__init__.py index b4bdc12..5fdc2e5 100644 --- a/xcore/sdk/__init__.py +++ b/xcore/sdk/__init__.py @@ -21,6 +21,10 @@ from xcore.sdk import PluginManifest """ +import importlib +import importlib.util +from typing import TYPE_CHECKING + from ..kernel.api import ( AuthBackend, AuthPayload, @@ -36,8 +40,6 @@ from ..kernel.permissions.engine import PermissionDenied from ..kernel.runtime.state_machine import PluginState from ..services.xworker import WorkerService, task, task_registry -from .adapter.asyncsql import BaseAsyncRepository -from .adapter.syncsql import BaseSyncRepository from .decorators import ( RoutedPlugin, action, @@ -103,9 +105,6 @@ "unregister_auth_backend", "get_auth_backend", "has_auth_backend", - # DB adapters - "BaseAsyncRepository", - "BaseSyncRepository", # Events & Hooks "Event", "HookResult", @@ -117,6 +116,47 @@ "task_registry", ] +# ── Adaptateurs SQL (extra `db`) ─────────────────────────────────────────── +# SQLAlchemy est une dépendance OPTIONNELLE depuis la 2.7.0 (XCoreRuntime[db], +# [postgres], [sqlite]). Importer ces deux modules à l'import de `xcore.sdk` +# faisait planter `import xcore` — et donc le boot — sur un simple +# `pip install XCoreRuntime`. Ils sont donc résolus à la demande (PEP 562) : +# `from xcore.sdk import BaseAsyncRepository` fonctionne comme avant quand +# SQLAlchemy est installé, et lève une ImportError explicite sinon. +_SQL_ADAPTERS = { + "BaseAsyncRepository": ".adapter.asyncsql", + "BaseSyncRepository": ".adapter.syncsql", +} + +if TYPE_CHECKING: # pragma: no cover + from .adapter.asyncsql import BaseAsyncRepository + from .adapter.syncsql import BaseSyncRepository + +if importlib.util.find_spec("sqlalchemy") is not None: + # Absents de __all__ sans SQLAlchemy : `from xcore.sdk import *` ne doit pas + # lever pour une dépendance dont l'utilisateur n'a pas besoin. + __all__ += list(_SQL_ADAPTERS) + + +def __getattr__(name: str): + module_name = _SQL_ADAPTERS.get(name) + if module_name is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + try: + module = importlib.import_module(module_name, __name__) + except ModuleNotFoundError as e: + if (e.name or "").split(".")[0] == "sqlalchemy": + raise ImportError( + f"xcore.sdk.{name} requires SQLAlchemy, an optional dependency " + "since XCoreRuntime 2.7.0 — install it with: " + "pip install 'XCoreRuntime[db]'" + ) from e + raise + value = getattr(module, name) + globals()[name] = value # mis en cache : __getattr__ n'est appelé qu'une fois + return value + + # ── Fonctionnalités xcoresdk sans équivalent local ───────────────────────── # N'existent que si le package externe `xcoresdk` est installé en plus. # Absentes ici plutôt que simulées : un faux no-op serait pire qu'une