diff --git a/CHANGELOG.md b/CHANGELOG.md index cfffe573..360af9b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,119 @@ 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-10-01 + +### 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. + +### 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 +- **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 +- **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 +- **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 +- **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 +- **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 +- **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 +- **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 +- **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/CLAUDE.md b/CLAUDE.md index b8c3fbd5..0dab1596 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.7.0** — 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 cfffe573..360af9b0 100644 --- a/doc/changelog.md +++ b/doc/changelog.md @@ -5,6 +5,119 @@ 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-10-01 + +### 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. + +### 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 +- **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 +- **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 +- **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 +- **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 +- **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 +- **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 +- **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 +- **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/plugins/trusted-plugins.md b/doc/plugins/trusted-plugins.md index bac412b3..ae89d4a3 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/doc/sdk/api/adapters.md b/doc/sdk/api/adapters.md index 90ab67c3..3e771d7e 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/poetry.lock b/poetry.lock index 54fe27b2..d623b63e 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 60e03387..5c3443a6 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/reports/sandbox_dynamic_security_analysis_2026-09-28.md b/reports/sandbox_dynamic_security_analysis_2026-09-28.md new file mode 100644 index 00000000..f4ba5e04 --- /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(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). + +### #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(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. + +### #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 3909b6c2..453a6a70 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 | @@ -98,17 +100,21 @@ 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]. +- **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`. +- **`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). diff --git a/roadmap/V3_NATIVE_RUNTIME_SPEC.md b/roadmap/V3_NATIVE_RUNTIME_SPEC.md new file mode 100644 index 00000000..a78b36b1 --- /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 2e1204a9..8f0c2df7 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é | @@ -98,17 +100,21 @@ 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]. +- **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é. +- **`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). diff --git a/tests/unit/kernel/test_blocking_sources.py b/tests/unit/kernel/test_blocking_sources.py new file mode 100644 index 00000000..51112e22 --- /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_context.py b/tests/unit/kernel/test_context.py index fc9f6af5..ec5019c3 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_ephemeral_isolation.py b/tests/unit/kernel/test_ephemeral_isolation.py new file mode 100644 index 00000000..9fa37eef --- /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_lifecycle.py b/tests/unit/kernel/test_lifecycle.py index 2e66c301..0ccefa46 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_lifecycle_reload.py b/tests/unit/kernel/test_lifecycle_reload.py new file mode 100644 index 00000000..2a0cc90e --- /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_lifecycle_unload_cleanup.py b/tests/unit/kernel/test_lifecycle_unload_cleanup.py new file mode 100644 index 00000000..65b65c5d --- /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/tests/unit/kernel/test_loop_block_watch.py b/tests/unit/kernel/test_loop_block_watch.py new file mode 100644 index 00000000..4bd55b23 --- /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_plugin_gc_scheduler.py b/tests/unit/kernel/test_plugin_gc_scheduler.py new file mode 100644 index 00000000..74ec7ecd --- /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/kernel/test_plugin_release_watcher.py b/tests/unit/kernel/test_plugin_release_watcher.py new file mode 100644 index 00000000..4d9b472f --- /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_process_manager.py b/tests/unit/kernel/test_process_manager.py index 66ed14c1..e5d0e6fe 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,112 @@ 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 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 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 + + 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, + ) + 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/tests/unit/kernel/test_sandbox_cpu_budget.py b/tests/unit/kernel/test_sandbox_cpu_budget.py new file mode 100644 index 00000000..2d68f4a9 --- /dev/null +++ b/tests/unit/kernel/test_sandbox_cpu_budget.py @@ -0,0 +1,182 @@ +""" +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( + monkeypatch, +): + """ + 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). + """ + from xcore.kernel.sandbox import worker + + 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(): + 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/tests/unit/kernel/test_state_machine.py b/tests/unit/kernel/test_state_machine.py index a64e9542..067eaadb 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/tests/unit/kernel/test_supervisor_exported_services.py b/tests/unit/kernel/test_supervisor_exported_services.py new file mode 100644 index 00000000..f08d6351 --- /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/plugins/test_base.py b/tests/unit/plugins/test_base.py index 815cb6ca..55cd2af5 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 5ab77892..5d432b61 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/tests/unit/test_optional_dependencies.py b/tests/unit/test_optional_dependencies.py new file mode 100644 index 00000000..f5241bb6 --- /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/tests/unit/test_registry_index.py b/tests/unit/test_registry_index.py index 7efdc81b..b0e30268 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 @@ -156,3 +163,27 @@ 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 + + +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 00000000..21c7ebaf --- /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 5d1589cc..15398beb 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") @@ -233,6 +237,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) @@ -280,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: @@ -294,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: @@ -311,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: @@ -323,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( @@ -365,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 0be1fae4..50414e6a 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" diff --git a/xcore/configurations/loader.py b/xcore/configurations/loader.py index 893af1f5..b2a40d4e 100644 --- a/xcore/configurations/loader.py +++ b/xcore/configurations/loader.py @@ -197,6 +197,8 @@ 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), + 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 7de7420c..161a2bf7 100644 --- a/xcore/configurations/sections.py +++ b/xcore/configurations/sections.py @@ -273,6 +273,12 @@ 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 + # 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/api/context.py b/xcore/kernel/api/context.py index eaf74d2a..c9799fc3 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: @@ -71,8 +94,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/context.py b/xcore/kernel/context.py index 7a9cfa9c..ffde7be5 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/events/hooks.py b/xcore/kernel/events/hooks.py index 44cfae2c..f4496422 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 00000000..c3f22f78 --- /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/activator.py b/xcore/kernel/runtime/activator.py index 160df1db..7d48a0a9 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/runtime/ephemeral_handler.py b/xcore/kernel/runtime/ephemeral_handler.py index a9f1fabf..ebe5f3b8 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 9d6c3a3a..0bdaa280 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 @@ -24,11 +27,95 @@ from ..api.context import PluginContext from ..api.contract import BasePlugin from ..observability import get_logger -from .plugin_gc import PluginResourceTracker +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. +_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.""" @@ -41,14 +128,32 @@ 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 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 @@ -72,6 +177,20 @@ 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] = {} + # 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] = {} + + # 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, @@ -133,6 +252,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: @@ -140,7 +260,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 @@ -206,6 +326,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) @@ -273,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") @@ -285,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: @@ -316,6 +463,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 +481,49 @@ 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 _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 # 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é. @@ -350,15 +542,28 @@ async def _do_unload(self) -> 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: + if self._registry is not None and self._manages_registry: self._registry.unregister(self.manifest.name) - module_name = f"xcore_plugin_{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 = 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) @@ -366,8 +571,19 @@ async def _do_unload(self) -> 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 ──────────────────────────────────── @@ -409,7 +625,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, @@ -422,6 +638,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 +680,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 +714,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 +732,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 +753,28 @@ 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) + } + self._exported_to_container.update(exported) 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", @@ -547,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/runtime/plugin_gc.py b/xcore/kernel/runtime/plugin_gc.py index 7df83234..726bd3b3 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/kernel/runtime/state_machine.py b/xcore/kernel/runtime/state_machine.py index f0a39d59..e6719788 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, + }, } diff --git a/xcore/kernel/runtime/supervisor.py b/xcore/kernel/runtime/supervisor.py index c76051af..29794005 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 7af1cdd8..aac98521 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/kernel/sandbox/isolation.py b/xcore/kernel/sandbox/isolation.py index d6ab968f..a9a519e6 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 a06ba9c4..2b0c53b0 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 @@ -20,10 +21,24 @@ 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") +# 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", + "WARNING": "warning", + "ERROR": "error", + "CRITICAL": "critical", +} + class ProcessState(Enum): STOPPED = "stopped" @@ -39,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: @@ -54,6 +79,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 +90,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 +123,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( @@ -134,7 +165,9 @@ 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 @@ -193,20 +226,70 @@ 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 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(), - ) + 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: + """ + 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 + while True: + try: + line = await stream.readline() + 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 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) while self._state == ProcessState.RUNNING: @@ -237,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: @@ -251,12 +339,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 +359,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 +384,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: diff --git a/xcore/kernel/sandbox/worker.py b/xcore/kernel/sandbox/worker.py index cab666e7..23e443b2 100644 --- a/xcore/kernel/sandbox/worker.py +++ b/xcore/kernel/sandbox/worker.py @@ -23,15 +23,24 @@ from pathlib import Path 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). _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") @@ -56,22 +65,160 @@ 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 +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. @@ -224,10 +371,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: @@ -277,6 +429,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}")) @@ -295,6 +449,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}")) @@ -304,6 +460,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 +597,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 @@ -622,7 +791,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)) @@ -630,7 +799,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 @@ -694,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) @@ -740,6 +911,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 @@ -764,6 +943,7 @@ def connection_lost(self, exc): _send(transport, response) break else: + _arm_cpu_budget() result = await plugin.handle(action, payload) response = ( result @@ -790,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() @@ -800,6 +989,7 @@ def connection_lost(self, exc): plugin._import_hook.uninstall() logger.info("sandbox worker stopped") + return recycle # ───────────────────────────────────────────────────────────────────────────── @@ -817,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/kernel/security/validation.py b/xcore/kernel/security/validation.py index 574f7324..608f0f49 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/registry/index.py b/xcore/registry/index.py index d9ad112f..448b16bf 100644 --- a/xcore/registry/index.py +++ b/xcore/registry/index.py @@ -135,6 +135,17 @@ 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.""" + 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 diff --git a/xcore/sdk/__init__.py b/xcore/sdk/__init__.py index b4bdc12f..5fdc2e56 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 diff --git a/xcore/sdk/manifest_schema.json b/xcore/sdk/manifest_schema.json index 9d427f15..89a16fcc 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 e723645e..c496ef01 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) diff --git a/xcore/services/scheduler/service.py b/xcore/services/scheduler/service.py index 94285f18..b05158ad 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):