diff --git a/.Jules/palette.md b/.Jules/palette.md deleted file mode 100644 index 19099b7a..00000000 --- a/.Jules/palette.md +++ /dev/null @@ -1,39 +0,0 @@ -## 2025-05-14 - [Enhancing CLI with Rich] -**Learning:** Transitioning from plain text to `rich` components like Panels and Status spinners significantly improves the perceived responsiveness and readability of terminal interfaces without adding heavy overhead. -**Action:** Use `rich.console.Status` for network-bound CLI operations and `rich.panel.Panel` to group related metadata for better visual hierarchy. - -## 2025-05-15 - [Structured CLI Data with Rich Tables] -**Learning:** For command outputs involving multiple entity attributes (like name, version, status), using structured tables with clear headers and semantic colors (e.g., green for OK, red for error) drastically reduces cognitive load compared to manual padding. -**Action:** Prefer `rich.table.Table` over custom string formatting for any CLI output that lists more than two related properties. - -## 2025-05-16 - [Safe Destructive Prompts in CLI] -**Learning:** When using `rich.prompt.Confirm.ask()` for destructive actions like plugin removal, the default behavior is `True` (Yes). To prevent accidental data loss, explicitly set `default=False` to require an intentional confirmation. -**Action:** Always use `Confirm.ask(..., default=False)` for deletions or other irreversible operations. - -## 2025-05-17 - [Defensive Metadata Display in CLI] -**Learning:** Metadata in plugin manifests (like `requires`) may be parsed into complex objects (e.g., `PluginDependency`) rather than simple strings. Directly using `", ".join()` on these lists causes a `TypeError`. -**Action:** Always use a generator or `map` with defensive attribute access (e.g., `d.name if hasattr(d, "name") else str(d)`) when joining metadata lists for display. - -## 2025-05-18 - [Visual Hierarchy with Rich Panels] -**Learning:** Grouping related metadata into a `rich.panel.Panel` using `rich.console.Group` creates a clear visual boundary and hierarchy, making dense information (like plugin specs and permissions) much easier to scan than plain text with manual separators. -**Action:** Use `Panel(Group(...))` for entity-detail commands to provide a "card-like" experience in the terminal. - -## 2025-05-19 - [Escaping Markup in Rich] -**Learning:** Dynamic content or error messages containing square brackets can be misinterpreted as `rich` markup, leading to broken layouts or missing text. -**Action:** Always wrap dynamic or user-provided strings in `rich.markup.escape()` before passing them to `rich` renderables. - -## 2025-05-20 - [Backward Compatibility in CLI UX] -**Learning:** Replacing raw JSON or plain-text CLI output with structured `rich` components (like Tables or Panels) improves human readability but breaks automation/scripts (e.g., `jq` pipes). -**Action:** When adding visual enhancements to CLI commands that return structured data, always provide a `--json` flag to maintain machine-readability. - -## 2025-05-21 - [Perceived Responsiveness with Status Spinners] -**Learning:** Using `rich.console.Status` context managers to wrap long-running operations (like sandbox startup or network calls) provides immediate visual feedback, making the application feel faster and more responsive even if the actual execution time remains the same. -**Action:** Wrap any operation expected to take >500ms in a `console.status` spinner with a descriptive message. - -## 2025-05-22 - [Batch Operation Summaries] -**Learning:** Adding a summary line after a table in batch CLI operations (like a health check) provides an immediate "TL;DR" for the user, especially when dealing with many items, improving scanability. -**Action:** Provide a concise summary (e.g., success/failure counts) after rendering tables for batch operations. - -## 2025-05-23 - [Language Consistency in CLI] -**Learning:** Mixing languages (e.g., French and English) in CLI outputs creates a fragmented and confusing user experience. -**Action:** Maintain strict language consistency across all user-facing strings, adhering to the primary language of the codebase (English). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8838c59..8f791c45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,7 @@ jobs: run: poetry install --no-interaction - name: Run lint with makefile - run: make lint-fix + run: make lint-check # ── Type checking ────────────────────────────────────────────────────────── typecheck: @@ -157,42 +157,8 @@ jobs: - name: Install dependencies run: poetry install --no-interaction - - name: Validate all plugins structure - run: | - poetry run python -c " - import os, sys, importlib.util - plugins_dir = 'plugins' - errors = [] - for name in os.listdir(plugins_dir): - path = os.path.join(plugins_dir, name) - if not os.path.isdir(path): continue - run_py = os.path.join(path, 'run.py') - init_py = os.path.join(path, '__init__.py') - if not os.path.exists(run_py): - errors.append(f'{name}: missing run.py') - continue - if not os.path.exists(init_py): - errors.append(f'{name}: missing __init__.py') - continue - spec = importlib.util.spec_from_file_location(name, run_py) - mod = importlib.util.module_from_spec(spec) - try: - spec.loader.exec_module(mod) - except Exception as e: - errors.append(f'{name}: import error – {e}') - continue - if not hasattr(mod, 'PLUGIN_INFO'): - errors.append(f'{name}: missing PLUGIN_INFO') - if not hasattr(mod, 'Plugin'): - errors.append(f'{name}: missing Plugin class') - if not hasattr(mod, 'router'): - errors.append(f'{name}: missing router') - if errors: - print('Plugin validation FAILED:') - for e in errors: print(' ✗', e) - sys.exit(1) - print(f'All plugins valid ✓') - " + - name: Validate all plugins + run: make validate-plugins # ── Résumé du CI ─────────────────────────────────────────────────────────── ci-success: diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 6c7d14af..b2ffe034 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -52,7 +52,7 @@ jobs: pip install -r doc/requirements.txt - name: Build site - run: mkdocs build + run: mkdocs build - name: Upload artifact (Pages) uses: actions/upload-pages-artifact@v3 @@ -72,4 +72,4 @@ jobs: steps: - name: Deploy id: deployment - uses: actions/deploy-pages@v4 \ No newline at end of file + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index fedeea26..88759bd1 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -83,22 +83,24 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Check modified plugins have config.yaml + - name: Check modified plugins have plugin.yaml run: | MODIFIED=$(git diff --name-only origin/${{ github.base_ref }}...HEAD \ - | grep '^plugins/' | cut -d'/' -f1,2 | sort -u) + | grep '^app/' | cut -d'/' -f1,2 | sort -u) + + if [ -z "$MODIFIED" ]; then + echo "Aucun plugin modifié." + exit 0 + fi for plugin_dir in $MODIFIED; do if [ -d "$plugin_dir" ]; then - if [ ! -f "$plugin_dir/config.yaml" ]; then - echo "⚠️ Warning: $plugin_dir is missing config.yaml" - fi - if [ ! -f "$plugin_dir/__init__.py" ]; then - echo "❌ Error: $plugin_dir is missing __init__.py" + if [ ! -f "$plugin_dir/plugin.yaml" ]; then + echo "❌ Error: $plugin_dir is missing plugin.yaml" exit 1 fi - if [ ! -f "$plugin_dir/run.py" ]; then - echo "❌ Error: $plugin_dir is missing run.py" + if [ ! -d "$plugin_dir/src" ]; then + echo "❌ Error: $plugin_dir is missing src/ directory" exit 1 fi echo "✓ $plugin_dir structure OK" diff --git a/.gitignore b/.gitignore index 76209e01..1f8649ac 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,4 @@ main.py summaries.json /IAdocs +scanner_core.cpython-312-x86_64-linux-gnu.so diff --git a/.jules/bolt.md b/.jules/bolt.md deleted file mode 100644 index 2b99e15a..00000000 --- a/.jules/bolt.md +++ /dev/null @@ -1,43 +0,0 @@ -## 2025-03-04 - [Optimization of EventBus dispatch overhead] -**Learning:** `inspect.iscoroutinefunction` is surprisingly expensive when called in hot paths like event emission or permission checks (~30x slower than a boolean check). Furthermore, creating per-emission wrapper coroutines for synchronous handlers significantly increases dispatch latency and GC pressure. -**Action:** Always pre-calculate and cache the `is_async` status of callable handlers during the registration/subscription phase. In emission loops, use this cached flag to branch between direct calls and `await` calls to minimize overhead. - -## 2025-05-14 - [Memoization in Permission Evaluation] -**Learning:** Permission evaluation involving glob patterns (via `fnmatch`) is computationally expensive, especially when performed on every plugin call. In XCore, this was consuming ~53µs per call set (multiple resources/actions). -**Action:** Implement a memoization layer (simple dictionary cache) for permission check results. This reduced evaluation time by ~70%, bringing it down to ~16µs. Always invalidate the cache when underlying policies are modified. - -## 2026-03-19 - [Batching Cache Operations] -**Learning:** Sequential network round-trips for multiple cache operations (individual GET/SET) are extremely expensive in distributed environments (e.g., Redis). Using native batching (MGET) or pipelining (MSET with individual TTLs) reduces the total latency from $O(N \times \text{latency})$ to $O(\text{latency})$. -**Action:** Implement and use dedicated `mget` and `mset` methods in cache backends. Use `MGET` for batch reads and `pipeline` for batch writes with individual TTLs in the Redis backend. - -## 2026-03-25 - [Short-circuiting Permission Matching] -**Learning:** In the `Policy.matches` method, evaluating resource glob patterns (via `fnmatch.fnmatch`) before checking for action matches is inefficient because string pattern matching is significantly slower than list lookups. Since many checks fail on the action, reversing the order provides an "early exit" that can speed up non-matching evaluations by ~80%. -**Action:** Always prioritize low-cost boolean or membership checks (like action lookups) before high-cost operations (like regex or glob matching) in hot-path evaluation logic. - -## 2026-04-10 - [Inlining Logic in MemoryBackend Batch Operations] -**Learning:** Inlining `get` and `set` logic within `mget` and `mset` methods of an in-memory cache backend significantly reduces performance overhead. This improvement comes from two sources: avoiding the creation and awaiting of individual coroutines for every key in the batch, and reducing the number of calls to `time.monotonic()` (and other redundant calculations) by performing them once per batch. -**Action:** For performance-critical batch operations in synchronous or asynchronous in-memory stores, avoid simple loops that delegate to individual item handlers. Instead, inline the core logic to minimize call stack depth, coroutine overhead, and redundant system calls. - -## 2026-04-20 - [Optimizing Plugin Call Path & Concurrency] -**Learning:** Using a formal state machine with `READY` -> `RUNNING` -> `READY` transitions for every plugin call introduces significant overhead (~10µs per call) and, more importantly, prevents concurrency because the state machine enforces sequential transitions. -**Action:** Remove transitional states (like `RUNNING`) from the high-frequency call path. Replace expensive state transitions with a fast boolean check (`is_available`) to ensure the plugin is `READY` before execution. This enables concurrent plugin calls and reduces per-call overhead by ~90% (to ~0.8µs). - -## 2026-04-25 - [Optimization of RateLimiter and Registry] -**Learning:** Using `asyncio.Lock` and `async def` for pure in-memory operations (like rate limiting with a `deque`) introduces unnecessary coroutine overhead and context switching. In a single-threaded event loop, synchronous operations are effectively atomic if they don't contain `await` points, making the lock redundant. -**Action:** Convert hot-path in-memory logic to synchronous methods and remove `asyncio.Lock` where I/O is not involved. This yielded a ~2.2x speedup in the rate-limiting check, reducing overhead from ~1.86µs to ~0.85µs. - -## 2026-05-01 - [Pre-compilation of Permission Patterns] -**Learning:** `fnmatch.fnmatch` is relatively slow for high-frequency calls because it may involve redundant pattern parsing or internal cache lookups. For literal (non-wildcard) patterns, a simple string comparison is orders of magnitude faster. For true wildcard patterns, pre-compiling the glob into a regex during initialization reduces evaluation overhead by ~50%. -**Action:** In classes that perform frequent pattern matching, identify literal patterns and use string equality. For wildcard patterns, pre-compile them into regexes during the object's initialization phase to avoid overhead in the hot execution path. - -## 2026-04-04 - [Optimizing Middleware Pipeline by Eliminating Per-Call Closures] -**Learning:** The used to create a new lambda function (closure) for the `next_call` parameter at every step of every execution. In high-frequency plugin call paths, this constant allocation and garbage collection overhead is significant. By refactoring the middleware signature to pass the `handler` as a positional argument, we can pass the next compiled step directly without any wrapping closures. -**Action:** Change `Middleware.__call__` and all middleware implementations to accept `handler` as a positional argument. Update the pipeline compilation to pass the next step directly. This reduced pipeline overhead by ~40% (~9.0µs down to ~5.3µs per call with 5 middlewares). - -## 2026-05-15 - [EventBus dispatch optimization with pre-compiled wildcards] -**Learning:** Iterating over all registered event patterns with `fnmatch.fnmatch` during dispatch is an $O(N)$ operation that becomes a bottleneck as the number of handlers grows. Exact match lookups should be $O(1)$, and wildcard lookups should only iterate over actual wildcard patterns. -**Action:** Separate exact and wildcard subscriptions. Use an exact dictionary lookup for dispatch and pre-compile wildcard patterns into regexes during the subscription phase. Additionally, store the source pattern in the handler entry to allow $O(1)$ cleanup of one-shot handlers. This yielded a ~3.7x speedup for exact matches and a ~47x speedup for no-match cases. - -## 2025-05-20 - [Optimizing Middleware Pipeline by Eliminating Per-Call Closures] -**Learning:** The `MiddlewarePipeline` used to create a new lambda function (closure) for the `next_call` parameter at every step of every execution. In high-frequency plugin call paths, this constant allocation and garbage collection overhead is significant. By refactoring the middleware signature to pass the `handler` as a positional argument, we can pass the next compiled step directly without any wrapping closures. -**Action:** Change `Middleware.__call__` and all middleware implementations to accept `handler` as a positional argument. Update the pipeline compilation to pass the next step directly. This reduced pipeline overhead by ~40% (~9.0µs down to ~5.3µs per call with 5 middlewares). diff --git a/.jules/sentinel.md b/.jules/sentinel.md deleted file mode 100644 index e3168c99..00000000 --- a/.jules/sentinel.md +++ /dev/null @@ -1,59 +0,0 @@ -## 2025-03-13 - Zip Slip Protection in Plugin Installation -**Vulnerability:** The plugin installation process was vulnerable to Zip Slip (directory traversal) because it extracted files from a ZIP archive without verifying that the resolved target paths remained within the intended destination directory. -**Learning:** Naive path concatenation using `Path` objects (`dest / member`) does not prevent traversal if the member name contains `..` components. Even if the first level is stripped, nested traversals can still occur. -**Prevention:** Always resolve the final path and check it against the destination using `target.resolve().is_relative_to(dest.resolve())`. This ensures that even with complex relative paths or symlinks, the file will not be written outside the sandbox. - -## 2026-03-14 - Directory Traversal in Dotenv Injection -**Vulnerability:** The `ManifestValidator._inject_dotenv` method allowed loading `.env` files from outside the plugin directory via the `env_file` manifest parameter. -**Learning:** Even when using `Path` objects, concatenating a base directory with a user-provided relative path containing `..` can escape the intended directory if not explicitly validated after resolution. -**Prevention:** Always use `.resolve()` on the final path and verify it stays within the intended base directory using `.is_relative_to(base_dir.resolve())`. - -## 2026-03-19 - Path Traversal in CLI Plugin Management -**Vulnerability:** CLI commands (`install`, `remove`, `info`, etc.) that accepted a plugin name as an argument were vulnerable to path traversal because the name was used to construct filesystem paths without validation. An attacker could use names like `../../etc/passwd` or absolute paths to target arbitrary locations. -**Learning:** CLI arguments should be treated as untrusted user input, especially when used in filesystem operations. Concatenating a base directory with an unvalidated argument can escape the intended directory. -**Prevention:** Implement strict input validation for names and identifiers using allow-lists (e.g., regex `^[a-zA-Z0-9_-]+$`) before using them in filesystem or shell operations. - -## 2026-03-24 - AST Sandbox Escape via Built-ins and Sensitive Attributes -**Vulnerability:** The `ASTScanner` was only monitoring imports, allowing plugins to use dangerous built-ins (e.g., `eval`, `exec`, `getattr`) and access sensitive internal attributes (e.g., `__subclasses__`, `__globals__`) to escape the sandbox. -**Learning:** A security scanner that only checks `import` statements is insufficient for Python, as many powerful features are available as built-ins or reachable through attribute traversal from any object. -**Prevention:** Use an AST visitor to monitor `ast.Name` (for built-ins) and `ast.Attribute` (for sensitive internals) in addition to imports. Maintain a strict blocklist of forbidden names and attributes. - -## 2026-03-27 - Weak Hashing for Plugin UID Generation -**Vulnerability:** The `xcore/kernel/sandbox/worker.py` used `hashlib.md5` to generate deterministic UIDs for plugins based on their directory paths. MD5 is considered cryptographically broken and often flagged by security scanners (Bandit B324). -**Learning:** Using legacy hashing algorithms like MD5, even for non-cryptographic purposes like identifier generation, can trigger security audits and is generally discouraged in favor of more robust algorithms. -**Prevention:** Always prefer SHA256 or better for any hashing operation to ensure both security and compliance with modern safety standards. - -## 2026-04-02 - AST Sandbox Bypass via Attribute-based Module Re-exports -**Vulnerability:** The `ASTScanner` was vulnerable to a bypass where sandboxed plugins could access forbidden modules (e.g., `os`, `sys`) if they were re-exported as attributes of allowed modules (e.g., `pathlib.os` or `importlib.sys`). -**Learning:** In Python, many standard library modules import and expose other sensitive modules as attributes. Blocking direct `import` statements and common dunder attributes is not enough to prevent access to forbidden functionality. -**Prevention:** Enhance the AST visitor's `visit_Attribute` method to check accessed attribute names against the full set of forbidden module names, ensuring that re-exported modules are also blocked. - -## 2026-04-10 - AST Sandbox Probing via `hasattr` -**Vulnerability:** The `ASTScanner` allowed the use of the `hasattr` built-in, enabling sandboxed plugins to probe for the existence of sensitive internal attributes (e.g., `__class__`, `__subclasses__`) without triggering an access error. -**Learning:** Security sandboxes that block attribute access (like `getattr` or direct attribute access) can still be "fingerprinted" or probed using `hasattr`. This allows an attacker to discover which objects might be vulnerable to escape before attempting a blocked operation. -**Prevention:** Always include `hasattr` in the list of forbidden built-ins for restricted execution environments, alongside `getattr`, `setattr`, and `delattr`. - -## 2026-04-15 - Sandbox Denial of Service via Interactive Input -**Vulnerability:** Sandboxed plugins could call the built-in, causing the isolated worker process to hang indefinitely while waiting for user input on stdin. This allowed a malicious or buggy plugin to block its assigned worker and potentially consume resources without completing its task. -**Learning:** Even if a sandbox restricts filesystem and network access, interactive functions like `input()` can be used to cause process-level Denial of Service if the IPC mechanism relies on standard input/output. -**Prevention:** Always monkey-patch interactive built-ins in the restricted execution environment to raise a `PermissionError` immediately. Additionally, include these built-ins in the static AST scanner's forbidden list to catch them before execution. - -## 2026-04-20 - AST Sandbox Bypass via Custom Entry Point -**Vulnerability:** The `ASTScanner` was hardcoded to only scan the `src/` directory within a plugin. However, the execution environment (the `SandboxWorker`) used the `entry_point` from the plugin's manifest to determine the source code to load. An attacker could bypass the scan by placing malicious code in an entry point outside of `src/` (e.g., at the plugin root). -**Learning:** Security validation must be synchronized with the actual execution logic. If the scanner assumes a fixed directory structure that the runtime does not enforce, a gap is created where unvalidated code can be executed. -**Prevention:** Always derive the scanning target from the same configuration used by the runtime (e.g., the `entry_point`). Additionally, implement strict path validation (using `.resolve()` and `.is_relative_to()`) to prevent the scanner itself from being used as a vector for path traversal attacks via misconfigured manifests. - -## 2026-04-22 - Plugin Signature Bypass via Custom Entry Point -**Vulnerability:** The plugin signature verification (HMAC) was hardcoded to hash only the `src/` directory, ignoring the `entry_point` specified in the manifest. This allowed modifications to the actual code if it was located elsewhere (e.g., in an `app/` folder) without invalidating the signature. -**Learning:** Security mechanisms must be consistent across all layers. Fixing a vulnerability in a static scanner (AST) but leaving it in the xcore/kernel/security/signature.py integrity check (HMAC) creates a gap where the system trust is based on an incomplete set of verified files. -**Prevention:** Ensure all security components (scanners, signers, loaders) use the same manifest-driven logic to identify and resolve the plugin's code root. - -## 2026-04-25 - Path Traversal via Manifest Configuration in FilesystemGuard -**Vulnerability:** A malicious plugin manifest could configure access to host files (e.g., `/etc/passwd`) by providing absolute paths or parent-directory traversal sequences (`..`) in the `allowed_paths` or `denied_paths` settings. -**Learning:** Security policies derived from untrusted configuration files (like manifests) must be validated after resolution. Naive concatenation of a base directory with configuration paths can be bypassed if the configuration paths are absolute or contain traversal components. -**Prevention:** In the sandbox initialization, always resolve configuration paths and verify they remain within the intended sandbox boundary using `path.resolve().is_relative_to(sandbox_root.resolve())`. - -## 2026-04-15 - Sandbox Denial of Service via Interactive Input -**Vulnerability:** Sandboxed plugins could call the `input()` built-in, causing the isolated worker process to hang indefinitely while waiting for user input on stdin. This allowed a malicious or buggy plugin to block its assigned worker and potentially consume resources without completing its task. -**Learning:** Even if a sandbox restricts filesystem and network access, interactive functions like `input()` can be used to cause process-level Denial of Service if the IPC mechanism relies on standard input/output. -**Prevention:** Always monkey-patch interactive built-ins in the restricted execution environment to raise a `PermissionError` immediately. Additionally, include these built-ins in the static AST scanner's forbidden list to catch them before execution. diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..c439c091 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,56 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + args: ["--unsafe"] + - id: check-added-large-files + + - repo: local + hooks: + - id: autoflake + name: autoflake + entry: poetry run autoflake + language: system + types: [python] + args: ["--in-place", "--recursive", "--remove-unused-variables", "--ignore-init-module-imports"] + + - id: autopep8 + name: autopep8 + entry: poetry run autopep8 + language: system + types: [python] + args: ["--in-place", "--recursive"] + + - repo: https://github.com/pycqa/isort + rev: 5.13.2 + hooks: + - id: isort + + - repo: https://github.com/psf/black + rev: 24.10.0 + hooks: + - id: black + + - repo: https://github.com/pycqa/flake8 + rev: 7.1.1 + hooks: + - id: flake8 + args: ["--max-line-length=88", "--extend-ignore=E203"] + + - repo: https://github.com/PyCQA/bandit + rev: 1.8.3 + hooks: + - id: bandit + args: ["-r", "xcore"] + + - repo: local + hooks: + - id: make-test + name: Run project tests + entry: poetry run make test + language: system + always_run: true + pass_filenames: false diff --git a/GEMINI.md b/GEMINI.md index 4a94547b..9374e76c 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -1,35 +1,32 @@ # GEMINI.md - XCore Framework Context -This file provides a comprehensive overview of the XCore project to guide development and maintenance. - ## Project Overview - **XCore** is a high-performance, plugin-first orchestration framework built on top of **FastAPI**. It is designed to load, isolate, and manage modular extensions (plugins) in a secure, sandboxed environment. ### Core Architecture -- **Xcore Kernel**: The central orchestrator managing sub-systems. +- **Xcore Kernel**: The central orchestrator managing the runtime, sandbox, and communication. - **Service Container**: Manages shared services like Databases (SQLAlchemy), Caching (Redis/Memory), and Schedulers (APScheduler). -- **Plugin Supervisor**: Handles the lifecycle (load, boot, shutdown, reload) of plugins. -- **Event Bus & Hooks**: Facilitates communication between the core and plugins via events and hooks. -- **Observability**: Integrated logging, metrics (Prometheus/Memory), and tracing (OpenTelemetry). -- **Security**: Supports plugin signing, verification, and sandboxed execution to restrict resource access. +- **Plugin Supervisor**: Handles the lifecycle (load, boot, shutdown, reload) and security of plugins. +- **Event Bus & Hooks**: Facilitates communication between the core and plugins. +- **Observability**: Integrated logging, metrics, and tracing. +- **Security**: Supports plugin signing, manifest validation, and import/resource restriction. ### Main Technologies - **Language**: Python 3.11+ - **Framework**: FastAPI -- **Dependency Management**: Poetry +- **Dependency Management**: Poetry / UV - **Data Validation**: Pydantic v2 -- **ORM/Database**: SQLAlchemy 2.0, Alembic +- **ORM**: SQLAlchemy 2.0 - **Task Scheduling**: APScheduler -- **CLI**: Custom CLI built with Rich +- **CLI**: Rich-based custom CLI (`xcore`) -## Building and Running +--- -The project uses a `Makefile` to simplify common development tasks. +## Building and Running ### Prerequisites - Python 3.11 or higher -- [Poetry](https://python-poetry.org/docs/#installation) +- [Poetry](https://python-poetry.org/) ### Key Commands | Task | Command | Description | @@ -39,45 +36,49 @@ The project uses a `Makefile` to simplify common development tasks. | **Run (Dev)** | `make dev` | Run FastAPI server with auto-reload (port 8000) | | **Run (Prod)** | `make st` | Run FastAPI server in production mode | | **Test** | `make test` | Run the full test suite with coverage | -| **Lint & Format** | `make lint-fix` | Auto-format (Black, Isort) and fix linting (Autopep8, Autoflake) | +| **Lint & Format** | `make lint-fix` | Auto-format (Black, Isort) and fix linting | | **Security Audit** | `make auto-security` | Run Bandit security scans | | **Clean** | `make clean` | Remove Python cache and temporary files | | **CLI Help** | `poetry run xcore --help` | Access the framework's CLI tools | +--- + ## Development Conventions ### Coding Standards -- **Formatting**: Strictly follow `black` and `isort`. Run `make lint-fix` before committing. -- **Typing**: Use Python type hints extensively. +- **Formatting**: Strictly follow `black`, `isort`, and `autopep8`. Run `make lint-fix` before committing. +- **Typing**: Extensive use of Python type hints is mandatory. - **Naming**: Follow PEP 8 (snake_case for functions/variables, PascalCase for classes). ### Plugin Structure -Plugins should reside in the `plugins/` directory (created during development). +Plugins reside in the `plugins/` directory. ```text plugins/my_plugin/ -├── plugin.yaml # Manifest (metadata & entry point) +├── plugin.yaml # Manifest (metadata, permissions, entry point) ├── src/ -│ └── main.py # Entry point (inherits from BasePlugin or TrustedBase) +│ └── main.py # Plugin logic └── tests/ # Plugin-specific tests ``` ### Configuration -Configuration is managed via `xcore.yaml` and loaded through `xcore/configurations/loader.py`. -- **App**: Environment, debug mode, secret keys. -- **Plugins**: Directory path, execution mode settings. -- **Services**: Database URLs, cache backend, scheduler settings. -- **Observability**: Logging levels, metrics/tracing backends. +Managed via `xcore.yaml` (or `integation.yaml`) and loaded via `xcore/configurations/loader.py`. +- **Environment Overrides**: Supports `XCORE__SECTION__KEY` environment variables. +- **Substitution**: Supports `${VAR}` in configuration files. +- **Sections**: `app`, `plugins`, `services` (databases, cache, scheduler), `observability`, `security`, `marketplace`. ### Logging & Monitoring -- Logs are written to `log/app.log` by default. -- Use the numerous `make logs-*` targets (e.g., `make logs-live`, `make logs-error`, `make logs-stats`) for analysis. -- **XCore Dashboard**: Access a real-time web dashboard at `/plugin/ipc/dashboard` (default prefix) to monitor plugins, event flows, live logs, and system health. +- Logs are written to `log/app.log`. +- Use `make logs-live` for real-time analysis. +- Detailed log categories: `make logs-auth`, `make logs-db`, `make logs-plugins`, etc. + +--- ## Key Directories - `xcore/kernel/`: Core logic (runtime, sandbox, events, permissions). -- `xcore/services/`: Built-in service providers (DB, Cache, etc.). -- `xcore/sdk/`: Tools and decorators for plugin developers. +- `xcore/services/`: Built-in service providers (DB, Cache, Scheduler). +- `xcore/sdk/`: Tools and base classes for plugin developers. - `xcore/cli/`: Command-line interface implementation. - `xcore/registry/`: Plugin discovery and versioning. - `tests/`: Integration and unit tests. -- `doc/`: Project documentation (MkDocs/Sphinx). +- `doc/` & `docs-tech/`: Comprehensive project documentation. +- `docgen/`: Documentation generation pipeline. diff --git a/README.md b/README.md index 8b9256b4..4a6bd5b7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ⚡ XCore Framework -[![Version](https://img.shields.io/badge/version-2.0.0-blue.svg)](https://github.com/traoreera/xcore) +[![Version](https://img.shields.io/badge/version-2.1.1-blue.svg)](https://github.com/traoreera/xcore) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/) [![FastAPI](https://img.shields.io/badge/FastAPI-0.118+-green.svg)](https://fastapi.tiangolo.com/) @@ -18,10 +18,10 @@ flowchart TB X --> EB[Event Bus] X --> SC[Service Container] end - + PS --> Trusted[Trusted Plugins] PS --> Sandbox[Sandboxed Plugins] - + SC --> DB[(Database)] SC --> RD[(Redis)] SC --> SCH[Scheduler] @@ -46,17 +46,18 @@ flowchart TB 2. **Install dependencies**: ```bash - poetry install + make install ``` 3. **Run the development server**: ```bash - make run-dev + make dev ``` -### or use pip to install and use it - ```bash - uv add https://github.com/traoreera/xcore - ``` + +### Quick Install (uv) +```bash +uv add https://github.com/traoreera/xcore +``` --- @@ -88,11 +89,11 @@ from xcore import Xcore async def main(): app = Xcore() await app.boot() - + # Call a plugin action result = await app.plugins.call("users_plugin", "get_user", {"id": 1}) print(result) - + await app.shutdown() ``` @@ -110,28 +111,6 @@ plugins/my_plugin/ └── main.py # Core logic ``` -### Example `plugin.yaml` -```yaml -name: my_plugin -version: "2.0.0" -author: Your Name -description: "A sample plugin" -execution_mode: trusted # or "sandboxed" -framework_version: ">=2.0" -entry_point: src/main.py - -permissions: - - resource: "cache.*" - actions: ["read", "write"] - effect: allow - -resources: - timeout_seconds: 30 - rate_limit: - calls: 100 - period_seconds: 60 -``` - --- ## 🛠️ CLI Reference @@ -150,13 +129,24 @@ XCore comes with a powerful CLI for management and security. --- +## 🧪 Quality & Performance + +XCore is built with reliability and speed in mind: +- **Test Suite**: 370+ automated tests passing (Unit, Integration, Security). +- **Security**: Built-in AST scanning for forbidden imports and attribute access. +- **Benchmarks**: Integrated performance monitoring for core components. + +--- + ## 📜 Makefile Commands | Command | Description | | :--- | :--- | | `make init` | Initialize project (install + run) | -| `make test` | Run the test suite | -| `make lint-fix` | Auto-format code (Black, Isort) | +| `make test` | Run the full test suite (executed automatically on pre-commit) | +| `make benchmark` | Run performance benchmarks with pytest-benchmark | +| `make lint-fix` | Auto-format code (Black, Isort, Autopep8) | +| `make dev` | Run development server with auto-reload | | `make docker-dev` | Spin up development environment with Docker | | `make logs-live` | View real-time structured logs | @@ -171,6 +161,3 @@ This project is licensed under the **MIT License**. See the [LICENSE](LICENSE) f

Built with ❤️ by Xcore team's

- - - diff --git a/doc/architecture/overview.md b/doc/architecture/overview.md index dc84ee37..06eecf25 100644 --- a/doc/architecture/overview.md +++ b/doc/architecture/overview.md @@ -18,39 +18,39 @@ flowchart TB EB[📡 Event Bus
Messagerie async] PE[🛡️ Permission Engine
Contrôle d'accès] end - + subgraph Infra["🏗️ Infrastructure Partagée"] DB[(🗄️ Database
SQL/NoSQL)] CACHE[(⚡ Cache
Redis/Memory)] SCHED[⏰ Scheduler
APScheduler] LOG[📝 Logger
Structuré] end - + subgraph Plugins["🧩 Écosystème Plugins"] direction LR TP[🔐 Trusted Plugins
Processus principal] SP[📦 Sandboxed Plugins
Processus isolé] end - + subgraph App["🌐 Application"] FA[FastAPI
Routes HTTP] WS[WebSocket
Real-time] end - + App --> X X --> PS X --> SC X --> EB X --> PE - + SC --> Infra PS --> TP PS --> SP - + EB -.->|Pub/Sub| TP EB -.->|Pub/Sub| SP PE -.->|Audit| PS - + style Core fill:#E3F2FD,stroke:#1976D2,stroke-width:2px style Infra fill:#E8F5E9,stroke:#388E3C,stroke-width:2px style Plugins fill:#FFF3E0,stroke:#F57C00,stroke-width:2px @@ -87,37 +87,37 @@ sequenceDiagram Dev->>Main: uvicorn app:app Main->>X: boot(app) - + Note over X: 📋 Phase 1: Configuration X->>Cfg: Charger xcore.yaml X->>Cfg: Charger .env Cfg-->>X: Config validée - + Note over X: 🔧 Phase 2: Services X->>S: Initialiser DB X->>S: Initialiser Cache X->>S: Initialiser Scheduler S-->>X: ✅ Services Ready - + Note over X: 🧩 Phase 3: Plugins X->>L: Découvrir plugins/ L->>L: Parser plugin.yaml L->>DAG: Build dependency graph DAG->>DAG: Topological sort DAG-->>L: Ordre de chargement - + loop Chaque vague de plugins L->>P: Load plugin (trusted/sandboxed) P->>P: on_load() P-->>L: ✅ Ready end - + Note over X: 🌐 Phase 4: Routage X->>R: Monter routes FastAPI R->>R: Plugin routes R->>R: API routes R-->>X: ✅ Routes montées - + X-->>Main: Application prête Main-->>Dev: 🚀 Running on :8000 ``` @@ -129,23 +129,23 @@ gantt title Boot Sequence Timeline dateFormat X axisFormat %L - + section Configuration Charger YAML :a1, 0, 10 Charger ENV :a2, after a1, 5 Validation :a3, after a2, 5 - + section Services Database :b1, after a3, 20 Cache :b2, after a3, 15 Scheduler :b3, after a3, 10 - + section Plugins Discovery :c1, after b1, 10 DAG Build :c2, after c1, 15 Load Wave 1 :c3, after c2, 30 Load Wave 2 :c4, after c3, 30 - + section Routing Mount Routes :d1, after c4, 10 ``` @@ -159,9 +159,9 @@ Chaque plugin est géré par une **Finite State Machine (FSM)** pour assurer des ```mermaid stateDiagram-v2 [*] --> UNLOADED: Découverte - + UNLOADED --> LOADING: supervisor.load() - + state LOADING { [*] --> SCANNING: AST Scan SCANNING --> VALIDATING: Scan OK @@ -169,40 +169,40 @@ stateDiagram-v2 INSTANTIATING --> INITIALIZING: Entry point OK INITIALIZING --> READY: on_load() OK end - + LOADING --> FAILED: Erreur AST LOADING --> FAILED: Erreur Manifest LOADING --> FAILED: Erreur on_load() - + READY --> RELOADING: supervisor.reload() - + state RELOADING { [*] --> UNLOADING: on_unload() UNLOADING --> RELOADING_CODE: Recharger code RELOADING_CODE --> RE_INITIALIZING: Nouveau on_load() RE_INITIALIZING --> READY: Succès end - + RELOADING --> FAILED: Échec reload - + READY --> UNLOADED: supervisor.unload() FAILED --> UNLOADED: Cleanup FAILED --> RELOADING: Retry (max 3) - + note right of READY 🟢 État stable - Routes actives - Événements abonnés - Services connectés end note - + note right of FAILED 🔴 État terminal - Erreur enregistrée - Cleanup requis - Notification envoyée end note - + note right of UNLOADED ⚪ État initial/final - Code non chargé @@ -252,7 +252,7 @@ flowchart LR M3 --> M4 M4 -- "execute" --> B B -- "result" --> A - + style Kernel fill:#FFEBEE,stroke:#C62828,stroke-width:2px style M3 fill:#C62828,color:#fff ``` @@ -269,20 +269,20 @@ sequenceDiagram participant B as Plugin B A->>T: call("plugin_b", "action", payload) - + Note over T: 1. Tracing T->>T: Créer span ID T->>T: Injecter context - + Note over R: 2. Rate Limiting R->>R: Vérifier quota A R-->>T: ✅ Dans la limite - + Note over P: 3. Permission Audit P->>P: Charger policies P->>P: Vérifier A → B.action P-->>T: ✅ Autorisé - + Note over L: 4. Retry Logic L->>B: Exécuter action alt Succès @@ -321,24 +321,24 @@ flowchart TB PS[PluginSupervisor] WR[Worker Manager] end - + subgraph IPC["📡 JSON-RPC 2.0 Channel"] direction LR PIPE1[Pipe stdout] PIPE2[Pipe stdin] end - + subgraph Sandbox["📦 Sandbox Process"] SW[Sandbox Worker] AST[🛡️ AST Scanner] EX[Code Exécuté] end - + K --> PS PS --> WR WR --> IPC IPC --> Sandbox - + style Sandbox fill:#FFEBEE,stroke:#C62828 style AST fill:#C62828,color:#fff ``` @@ -351,19 +351,19 @@ flowchart LR A1[Parser AST] --> A2[Forbidden Names] A2 --> A3[Import Whitelist] end - + subgraph L2["Couche 2: Process Isolation"] B1[Processus dédié] --> B2[JSON-RPC sur pipes] B2 --> B3[Pas de pickle] end - + subgraph L3["Couche 3: Resource Limits"] C1[Memory Monitor] --> C2[Timeout Monitor] C2 --> C3[Kill si dépassement] end - + L1 --> L2 --> L3 - + style L1 fill:#FFE0B2,stroke:#F57C00 style L2 fill:#C8E6C9,stroke:#388E3C style L3 fill:#BBDEFB,stroke:#1976D2 @@ -422,23 +422,23 @@ flowchart TB PRI[🔴 Private Services
Kernel interne] SCO[🟡 Scoped Services
Plugins autorisés] end - + subgraph Access["Accès"] K[🎯 Kernel] T[🔐 Trusted Plugins] S[📦 Sandboxed Plugins] end - + PUB --> K PUB --> T PUB --> S - + PRI --> K - + SCO --> K SCO --> T SCO -.->|Si autorisé| S - + style PUB fill:#C8E6C9,stroke:#388E3C style PRI fill:#FFCDD2,stroke:#C62828 style SCO fill:#FFF9C4,stroke:#F57F17 @@ -462,7 +462,7 @@ services: scope: public backend: postgresql url: ${DATABASE_URL} - + # Service scoped - accessible uniquement par authorized plugins payment_db: scope: scoped @@ -471,7 +471,7 @@ services: - billing_plugin backend: postgresql url: ${PAYMENT_DATABASE_URL} - + # Service private - kernel uniquement audit_log: scope: private @@ -496,23 +496,23 @@ sequenceDiagram Client->>API: POST /plugins/call API->>X: plugins.call(plugin, action, payload) - + Note over X,P: 1. Permission Check X->>P: verify(plugin, action) P-->>X: ✅ Autorisé - + Note over X,S: 2. Resolve Plugin X->>S: get_handler(plugin, action) S-->>X: Handler trouvé - + Note over S,PL: 3. Execute Action S->>PL: @action(payload) - + alt Besoin DB PL->>DB: Query DB-->>PL: Résultats end - + PL-->>S: Résultat S-->>X: Response X-->>API: JSON diff --git a/doc/getting-started/quickstart.md b/doc/getting-started/quickstart.md index d7c7f33e..57a27602 100644 --- a/doc/getting-started/quickstart.md +++ b/doc/getting-started/quickstart.md @@ -11,7 +11,7 @@ flowchart LR A[1. Initialiser
l'Application] --> B[2. Créer
le Plugin] B --> C[3. Démarrer
le Serveur] C --> D[4. Tester
l'Action] - + style A fill:#4CAF50,color:#fff style B fill:#2196F3,color:#fff style C fill:#FF9800,color:#fff @@ -60,14 +60,14 @@ sequenceDiagram You->>App: uvicorn app:app App->>Kernel: boot() - + Note over Kernel: Initialisation... Kernel->>Services: DB, Cache, Scheduler Kernel->>Plugins: Chargement - + Services-->>Kernel: ✅ Prêts Plugins-->>Kernel: ✅ Chargés - + Kernel-->>App: ✅ Prêt App-->>You: 🚀 Serveur sur :8000 ``` @@ -130,30 +130,30 @@ class Plugin(AutoDispatchMixin, TrustedBase): """ Plugin de démonstration qui salue les utilisateurs. """ - + async def on_load(self): """🎯 Appelé quand le plugin est chargé.""" self.logger.info("✨ Hello plugin est prêt !") - + async def on_unload(self): """🧹 Appelé quand le plugin est déchargé.""" self.logger.info("👋 Hello plugin est déchargé") - + @action("greet") async def greet_user(self, payload: dict): """ 🎯 Action: greet - + Args: payload: {"name": "Votre nom"} - + Returns: {"status": "ok", "message": "Hello, X !"} """ name = payload.get("name", "World") self.logger.info(f"Salutation de: {name}") return ok(message=f"Hello, {name} !") - + @action("ping") async def ping(self, payload: dict): """Test de connectivité.""" @@ -216,14 +216,14 @@ sequenceDiagram CLI->>API: POST /plugins/call API->>Kernel: plugins.call("hello_plugin", "greet") - + Kernel->>Perm: Vérifier permissions Perm-->>Kernel: ✅ Autorisé - + Kernel->>Plugin: @action("greet") Plugin->>Plugin: greet_user(payload) Plugin-->>Kernel: {"status": "ok", ...} - + Kernel-->>API: Résultat API-->>CLI: Response JSON ``` @@ -238,12 +238,12 @@ sequenceDiagram from xcore.sdk import TrustedBase, RoutedPlugin, route class Plugin(RoutedPlugin, TrustedBase): - + @route("/hello/{name}", methods=["GET"]) async def hello_api(self, name: str): """Route HTTP accessible directement.""" return {"message": f"Hello, {name} !"} - + @route("/status", methods=["GET"]) async def status(self): """Endpoint de santé du plugin.""" @@ -264,24 +264,24 @@ class Plugin(RoutedPlugin, TrustedBase): from xcore.sdk import TrustedBase, AutoDispatchMixin, action, ok class Plugin(AutoDispatchMixin, TrustedBase): - + async def on_load(self): self.cache = self.get_service("cache") - + @action("greet_cached") async def greet_cached(self, payload: dict): name = payload.get("name", "World") cache_key = f"greeting:{name}" - + # Vérifier le cache cached = await self.cache.get(cache_key) if cached: return ok(message=cached, source="cache") - + # Générer et caster message = f"Hello, {name} !" await self.cache.set(cache_key, message, ttl=60) - + return ok(message=message, source="generated") ``` @@ -291,17 +291,17 @@ class Plugin(AutoDispatchMixin, TrustedBase): from xcore.sdk import TrustedBase, AutoDispatchMixin, action, ok class Plugin(AutoDispatchMixin, TrustedBase): - + @action("create_user") async def create_user(self, payload: dict): # ... logique de création ... - + # 📡 Émettre un événement await self.ctx.events.emit("user.created", { "id": user_id, "email": payload.get("email") }) - + return ok(user_id=user_id) ``` diff --git a/doc/guides/events.md b/doc/guides/events.md index b3cee715..494b0dca 100644 --- a/doc/guides/events.md +++ b/doc/guides/events.md @@ -14,17 +14,17 @@ flowchart LR E3[Pas de retour] E4[Side effects] end - + subgraph Hooks["🪝 Hooks (Sync)"] H1[Transformation] H2[Séquentiel] H3[Retour valeur] H4[Inline logic] end - + Events -.->|Découplage fort| Hooks Hooks -.->|Transformation| Events - + style Events fill:#E3F2FD,stroke:#1976D2 style Hooks fill:#FFF3E0,stroke:#F57C00 ``` @@ -44,28 +44,28 @@ flowchart TB P2[Plugin B] K[Kernel] end - + subgraph EventBus["📡 Event Bus"] direction TB Q[Queue Async] D[Dispatcher] R[Registry Handlers] end - + subgraph Subscriber["📥 Subscribers"] S1[Handler 1] S2[Handler 2] S3[Handler 3] end - + P1 --> EventBus P2 --> EventBus K --> EventBus - + EventBus --> S1 EventBus --> S2 EventBus --> S3 - + style EventBus fill:#E8F5E9,stroke:#388E3C ``` @@ -85,33 +85,33 @@ flowchart TB from xcore.sdk import TrustedBase, on, Event class Plugin(TrustedBase): - + async def on_load(self): # Méthode 1: Décorateur (recommandé) # Le handler est automatiquement abonné - + # Méthode 2: Subscription manuelle self.ctx.events.on("user.created", self._on_user_created, priority=100) - + @on("user.created", priority=100) async def _on_user_created(self, event: Event): """ Handler pour l'événement user.created - + Args: event: Contient data, timestamp, source """ user_id = event.data.get("id") email = event.data.get("email") - + self.logger.info(f"Nouvel utilisateur créé: {user_id}") - + # Side effect: envoyer email de bienvenue await self._send_welcome_email(email) - + # Optionnel: stopper la propagation # event.stop_propagation() - + async def _send_welcome_email(self, email: str): # ... logique d'envoi ... pass @@ -123,19 +123,19 @@ class Plugin(TrustedBase): from xcore.sdk import TrustedBase, action, ok class Plugin(TrustedBase): - + @action("create_user") async def create_user(self, payload: dict): # 1. Créer l'utilisateur user_id = await self._create_in_db(payload) - + # 2. Émettre l'événement await self.ctx.events.emit("user.created", { "id": user_id, "email": payload.get("email"), "created_at": self._now() }) - + # 3. Retourner résultat (l'événement est fire-and-forget) return ok(user_id=user_id) ``` @@ -151,21 +151,21 @@ sequenceDiagram participant H3 as Handler 3 (priority=50) P->>EB: emit("user.created", data) - + Note over EB: Registry handlers par priority EB->>H1: _on_user_created(event) - + par Exécution parallèle (asyncio.gather) EB->>H2: handler_2(event) EB->>H3: handler_3(event) end - + H1-->>EB: ✅ Done H2-->>EB: ✅ Done H3-->>EB: ✅ Done - + EB-->>P: ✅ Tous handlers exécutés - + Note over P,H3: ⚠️ Le publisher n'attend pas
les résultats des handlers ``` @@ -194,20 +194,20 @@ XCore émet plusieurs événements système que vous pouvez subscriber. from xcore.sdk import TrustedBase, on, Event class Plugin(TrustedBase): - + @on("xcore.plugins.booted", priority=200) async def _on_system_boot(self, event: Event): """Exécuté quand TOUS les plugins sont chargés.""" report = event.data.get("report", {}) loaded_count = report.get("loaded", 0) self.logger.info(f"🚀 Système prêt : {loaded_count} plugins chargés") - + @on("plugin.auth_plugin.loaded", priority=100) async def _on_auth_ready(self, event: Event): """Exécuté quand le plugin auth est prêt.""" self.logger.info("✅ Plugin auth disponible") # Initialiser la connexion avec le plugin auth - + @on("permission.deny", priority=50) async def _on_permission_denied(self, event: Event): """Logging de toutes les permissions refusées.""" @@ -228,16 +228,16 @@ flowchart TB D3[Cleanup handlers] D4[Événements nommés] end - + subgraph Dont["❌ Erreurs à Éviter"] A1[Tâches lourdes] A2[Priorités magiques] A3[Memory leaks] A4[Noms obscurs] end - + Do -.->|Au lieu de| Dont - + style Do fill:#E8F5E9,stroke:#388E3C style Dont fill:#FFEBEE,stroke:#C62828 ``` @@ -250,7 +250,7 @@ flowchart TB async def bad_handler(self, event): # Envoi email synchrone (lent !) send_email_sync(event.data["email"]) - + # Traitement lourd for i in range(1000000): process(i) @@ -300,7 +300,7 @@ await self.ctx.events.emit("order.completed", data) from xcore.sdk import TrustedBase, on, Event class Plugin(TrustedBase): - + @on("user.created", priority=100) async def validate_user(self, event: Event): """Validation en premier, peut bloquer les autres handlers.""" @@ -308,14 +308,14 @@ class Plugin(TrustedBase): self.logger.error("Utilisateur invalide") event.stop_propagation() # ⚠️ Stop les handlers suivants return - + @on("user.created", priority=50) async def send_welcome(self, event: Event): """Ne s'exécute que si validation passe.""" if event.propagation_stopped: self.logger.debug("Propagation stoppée, skip welcome") return - + # Envoyer email... ``` @@ -335,14 +335,14 @@ flowchart LR F3 --> F4[Hook 3] F4 --> F5[Modified Value] end - + subgraph Actions["⚡ Actions (Side Effects)"] A1[Trigger] --> A2[Hook 1] A2 --> A3[Hook 2] A3 --> A4[Hook 3] A4 --> A5[Done] end - + style Filters fill:#E3F2FD,stroke:#1976D2 style Actions fill:#FFF3E0,stroke:#F57C00 ``` @@ -357,7 +357,7 @@ flowchart LR H1 -->|Modifie| H2[Hook 2] H2 -->|Modifie| H3[Hook 3] H3 -->|Modifie| R[Valeur Finale] - + style V fill:#E8F5E9 style R fill:#C8E6C9 ``` @@ -365,19 +365,19 @@ flowchart LR ```python # Côté Kernel ou Plugin class Plugin(TrustedBase): - + async def render_page(self): # 1. Valeur de base title = "Bienvenue sur XCore" - + # 2. Appliquer les filters title = self.ctx.hooks.apply_filters("page_title", title) - + # title peut maintenant être modifié par d'autres plugins # Ex: "Bienvenue sur XCore | Dashboard" - + return f"

{title}

" - + # 3. Enregistrer un filter @filter("page_title") def modify_title(self, title: str) -> str: @@ -392,19 +392,19 @@ Les actions sont des side effects synchrones qui ne retournent pas de valeur. ```python # Côté Kernel ou Plugin class Plugin(TrustedBase): - + async def render_template(self, template_name: str): # 1. Trigger action avant rendu self.ctx.hooks.do_action("before_render", template=template_name) - + # 2. Rendu du template result = self._render(template_name) - + # 3. Trigger action après rendu self.ctx.hooks.do_action("after_render", template=template_name, result=result) - + return result - + # 4. Enregistrer un action hook @action_hook("before_render") def on_before_render(self, template: str): @@ -430,19 +430,19 @@ class Plugin(TrustedBase): ```mermaid flowchart TB Start{Besoin ?} - + Start -->|Modifier données| Hooks Start -->|Side effect| Events - + Start -->|Synchrone requis| Hooks Start -->|Async OK| Events - + Start -->|Plusieurs listeners| Events Start -->|Chaîne transformation| Hooks - + Hooks --> H[🪝 Hooks] Events --> E[📡 Events] - + style H fill:#FFF3E0,stroke:#F57C00 style E fill:#E3F2FD,stroke:#1976D2 ``` @@ -482,29 +482,29 @@ class NotificationPlugin(TrustedBase): """ Plugin de notification qui démontre Events + Hooks. """ - + async def on_load(self): # S'abonner aux événements système self.ctx.events.on("user.created", self._on_user_created, priority=50) self.ctx.events.on("order.completed", self._on_order_completed, priority=50) - + @on("user.created") async def _on_user_created(self, event): """Envoyer email de bienvenue.""" email = event.data.get("email") await self._send_email(email, "Bienvenue !") - + @on("order.completed") async def _on_order_completed(self, event): """Envoyer confirmation de commande.""" email = event.data.get("email") await self._send_email(email, "Commande confirmée !") - + @filter("notification.message") def add_disclaimer(self, message: str) -> str: """Ajouter un disclaimer à tous les messages.""" return f"{message}\n\n-- Ceci est un message automatique" - + @action("send_notification") async def send_notification(self, payload: dict): """Action publique pour envoyer une notification.""" @@ -513,13 +513,13 @@ class NotificationPlugin(TrustedBase): "notification.message", payload.get("message", "") ) - + # Émettre un événement pour logging await self.ctx.events.emit("notification.sent", { "to": payload.get("to"), "message": message }) - + return ok(status="sent") ``` diff --git a/doc/guides/security.md b/doc/guides/security.md index ea66f1c1..3ba8354e 100644 --- a/doc/guides/security.md +++ b/doc/guides/security.md @@ -16,7 +16,7 @@ flowchart TB L4[🛡️ Couche 4: Permissions
Policy Engine] L5[🛡️ Couche 5: Signature
HMAC Verification] end - + subgraph Attaques["Attaques Bloquées"] A1[Code Injection] A2[Sandbox Escape] @@ -24,13 +24,13 @@ flowchart TB A4[Accès Non Autorisé] A5[Tampering] end - + L1 -.->|Bloque| A1 L2 -.->|Bloque| A2 L3 -.->|Bloque| A3 L4 -.->|Bloque| A4 L5 -.->|Bloque| A5 - + style Defense fill:#E8F5E9,stroke:#388E3C style L1 fill:#C8E6C9 style L2 fill:#C8E6C9 @@ -55,19 +55,19 @@ flowchart LR C -->|Non| D[❌ Rejeté] C -->|Oui| E[📦 Spawn Worker] end - + subgraph Exec["Exécution"] E --> F[📡 JSON-RPC IPC] F --> G[⏱️ Timeout Monitor] G --> H[🧠 Memory Monitor] end - + subgraph PostExec["Post-Exécution"] H --> I{Limites OK ?} I -->|Non| J[❌ Kill Worker] I -->|Oui| K[✅ Résultat] end - + style PreExec fill:#FFF3E0,stroke:#F57C00 style Exec fill:#E3F2FD,stroke:#1976D2 style PostExec fill:#E8F5E9,stroke:#388E3C @@ -84,23 +84,23 @@ flowchart TB subgraph AST["🔍 AST Scanner"] S[Code Source] --> P[Parser AST] P --> N[NodeVisitor] - + N --> F1[Forbidden Names] N --> F2[Forbidden Attributes] N --> F3[Import Whitelist] - + F1 --> B1[❌ eval, exec] F1 --> B2[❌ globals, locals] F1 --> B3[❌ __import__] - + F2 --> B4[❌ __class__] F2 --> B5[❌ __globals__] F2 --> B6[❌ __subclasses__] - + F3 --> B7[❌ os, subprocess] F3 --> B8[❌ socket, requests] end - + style AST fill:#FFEBEE,stroke:#C62828 style B1 fill:#FFCDD2 style B2 fill:#FFCDD2 @@ -165,21 +165,21 @@ sequenceDiagram participant K as 🎯 Kernel Process participant M as 📡 Multiprocessing Manager participant W as 📦 Sandbox Worker - + Note over K: Boot du kernel K->>M: spawn_worker(plugin_path) - + Note over M: Création processus M->>W: fork()/spawn() W->>W: Charger code plugin - + Note over K,W: Communication via pipes K->>W: {"jsonrpc":"2.0","method":"call",...} Note over K,W: JSON uniquement
⚠️ Pas de pickle ! - + W->>W: Exécuter handler W->>K: {"jsonrpc":"2.0","result":...} - + Note over K: Désérialiser résultat ``` @@ -237,23 +237,23 @@ flowchart LR TM[⏱️ Timeout Monitor
Execution Timer] CM[📈 CPU Monitor
CPU Time Tracker] end - + subgraph Limits["⚠️ Limites"] ML[Max: max_memory_mb] TL[Max: timeout_seconds] CL[Max: cpu_seconds] end - + subgraph Actions["🔥 Actions"] MA[⚠️ Warning] KA[❌ Kill Process] RA[📝 Log Violation] end - + MM --> ML --> MA --> KA TM --> TL --> KA CM --> CL --> KA - + style Monitors fill:#E3F2FD,stroke:#1976D2 style Limits fill:#FFF3E0,stroke:#F57C00 style Actions fill:#FFEBEE,stroke:#C62828 @@ -266,10 +266,10 @@ flowchart LR resources: # Mémoire maximale (Mo) max_memory_mb: 256 - + # Timeout d'exécution (secondes) timeout_seconds: 30 - + # Rate limiting rate_limit: calls: 1000 @@ -302,13 +302,13 @@ flowchart TB Allow[✅ ALLOW] Deny[❌ DENY] end - + Start --> CheckDeny CheckDeny -->|Oui| Deny CheckDeny -->|Non| CheckAllow CheckAllow -->|Oui| Allow CheckAllow -->|Non| DefaultDeny - + style Deny fill:#FFCDD2,stroke:#C62828 style Allow fill:#C8E6C9,stroke:#388E3C style DefaultDeny fill:#FFCDD2,stroke:#C62828 @@ -334,17 +334,17 @@ permissions: - resource: "db.users.*" actions: ["read"] effect: allow - + # ✅ Autorise lecture/écriture sur cache global - resource: "cache.global" actions: ["read", "write"] effect: allow - + # ✅ Autorise TOUS les actions sur une resource - resource: "logs.*" actions: ["*"] effect: allow - + # ❌ DENY explicite (priorité haute) - resource: "db.admin.*" actions: ["*"] @@ -360,26 +360,26 @@ permissions: - resource: "db.main.users" actions: ["read"] effect: allow - + - resource: "db.main.posts" actions: ["read"] effect: allow - + # Écriture sur cache - resource: "cache.global" actions: ["read", "write"] effect: allow - + # Accès scheduler - resource: "scheduler.jobs" actions: ["create", "delete"] effect: allow - + # Interdiction explicite (sécurité défense) - resource: "db.admin.*" actions: ["*"] effect: deny - + - resource: "kernel.*" actions: ["*"] effect: deny @@ -396,16 +396,16 @@ sequenceDiagram A->>S: call("plugin_b", "get_user") S->>P: evaluate("plugin_a", "plugin_b.get_user") - + P->>L: Charger policies de plugin_a L-->>P: Retourner rules - + P->>P: Vérifier DENY explicite Note over P: ❌ Aucun DENY trouvé - + P->>P: Vérifier ALLOW explicite Note over P: ✅ Rule trouvée ! - + P-->>S: ✅ Autorisé S->>A: Procéder à l'appel ``` @@ -424,16 +424,16 @@ flowchart TB D1[Fichiers Plugin] --> D2[Calcul HMAC-SHA256] D2 --> D3[Générer plugin.sig] end - + subgraph Verify["🔍 Vérification (Production)"] V1[Charger plugin.sig] --> V2[Re-calculer hashes] V2 --> V3{Match ?} V3 -->|Oui| V4[✅ Charger] V3 -->|Non| V5[❌ Rejeté] end - + Sign --> Verify - + style Sign fill:#E8F5E9,stroke:#388E3C style Verify fill:#FFEBEE,stroke:#C62828 ``` @@ -473,11 +473,11 @@ sequenceDiagram participant Sig as plugin.sig K->>S: verify_trusted("my_plugin") - + S->>Sig: Charger signature S->>F: Lire fichiers actuels S->>S: Calculer hashes actuels - + S->>S: Comparer avec plugin.sig alt Hashes correspondent S-->>K: ✅ Vérifié @@ -503,7 +503,7 @@ flowchart LR D4[Pas d'état local] D5[Signer en prod] end - + subgraph Dont["❌ À Éviter"] A1[Trusted sans besoin] A2[Permissions larges] @@ -511,9 +511,9 @@ flowchart LR A4[État dans le plugin] A5[Code non signé] end - + Do -.->|Au lieu de| Dont - + style Do fill:#E8F5E9,stroke:#388E3C style Dont fill:#FFEBEE,stroke:#C62828 ``` @@ -560,7 +560,7 @@ class GreetInput(BaseModel): language: str = Field(default="fr") class Plugin(AutoDispatchMixin, TrustedBase): - + @action("greet") async def greet(self, payload: dict): # Valider l'input @@ -568,7 +568,7 @@ class Plugin(AutoDispatchMixin, TrustedBase): validated = GreetInput(**payload) except ValidationError as e: return error(msg=str(e), code="invalid_input") - + return ok(message=f"Bonjour, {validated.name} !") ``` @@ -584,7 +584,7 @@ class Plugin(TrustedBase): class Plugin(TrustedBase): async def on_load(self): self.cache = self.get_service("cache") # ✅ Persistant - + async def store(self, key, value): await self.cache.set(key, value) # ✅ Sauvegardé ``` diff --git a/doc/index.md b/doc/index.md index 6b19e37f..beb079f6 100644 --- a/doc/index.md +++ b/doc/index.md @@ -44,7 +44,7 @@ flowchart LR direction LR M[Monolithe
Rigide] <--> MM[**Monolithe Modulaire**
XCore] <--> MS[Microservices
Complexes] end - + style MM fill:#4CAF50,color:#fff,stroke:#2E7D32 style M fill:#FFC107,color:#000 style MS fill:#FFC107,color:#000 @@ -96,15 +96,15 @@ flowchart TB X --> PS X --> EB X --> PE - + SC --> Services PS --> TP PS --> SP - + EB -.->|Événements| TP EB -.->|Événements| SP PE -.->|Permissions| PS - + style Kernel fill:#E3F2FD,stroke:#1976D2 style Plugins fill:#FFF3E0,stroke:#F57C00 style Services fill:#E8F5E9,stroke:#388E3C @@ -127,24 +127,24 @@ sequenceDiagram Dev->>App: Lance l'application App->>Kernel: boot(app) - + Note over Kernel: 1. Configuration Kernel->>Kernel: Charge xcore.yaml + .env - + Note over Kernel: 2. Services Kernel->>Services: Initialise DB, Cache, Scheduler Services-->>Kernel: Services prêts - + Note over Kernel: 3. Plugins Kernel->>Loader: Découvre les plugins Loader->>Loader: Build DAG de dépendances Loader->>Loader: Tri topologique - + loop Chargement par vagues Loader->>P: Load (Trusted/Sandboxed) P-->>Loader: Ready end - + Kernel->>App: Monte les routes plugins App-->>Dev: Serveur prêt ! ``` @@ -154,25 +154,25 @@ sequenceDiagram ```mermaid stateDiagram-v2 [*] --> UNLOADED: Plugin découvert - + UNLOADED --> LOADING: supervisor.load() LOADING --> READY: on_load() ✅ LOADING --> FAILED: Erreur ❌ - + READY --> RELOADING: supervisor.reload() RELOADING --> READY: Mise à jour ✅ RELOADING --> FAILED: Échec ❌ - + READY --> UNLOADED: supervisor.unload() FAILED --> UNLOADED: Cleanup FAILED --> RELOADING: Retry - + note right of READY Plugin opérationnel Routes montées Événements abonnés end note - + note right of FAILED Erreur de syntaxe Dépendance manquante @@ -208,7 +208,7 @@ flowchart LR M3 --> M4 M4 -- "exécute" --> B B -- "résultat" --> A - + style Supervisor fill:#FFEBEE,stroke:#C62828 style M3 fill:#C62828,color:#fff ``` @@ -240,7 +240,7 @@ services: db: backend: "sqlite" url: "sqlite+aiosqlite:///./app.db" - + cache: backend: "memory" ttl: 300 @@ -286,7 +286,7 @@ entry_point: src/main.py from xcore.sdk import TrustedBase, AutoDispatchMixin, action, ok class Plugin(AutoDispatchMixin, TrustedBase): - + @action("greet") async def greet(self, payload: dict): name = payload.get("name", "Monde") diff --git a/doc/reference/sdk.md b/doc/reference/sdk.md index 32479d50..7d478da3 100644 --- a/doc/reference/sdk.md +++ b/doc/reference/sdk.md @@ -13,30 +13,30 @@ flowchart TB BP[BasePlugin
Interface low-level] SB[SandboxedBase
Base plugins sandboxed] end - + subgraph Mixins["🔧 Mixins"] ADM[AutoDispatchMixin
Routage @action] RP[RoutedPlugin
Routes HTTP] VM[ValidateMixin
Validation Pydantic] end - + subgraph Decorators["📝 Décorateurs"] D1[@action] D2[@route] D3[@validate_payload] D4[@on / @filter] end - + subgraph Helpers["🛠️ Helpers"] H1[ok / error] H2[Event] H3[Response types] end - + Core --> Mixins Mixins --> Decorators Decorators --> Helpers - + style Core fill:#E3F2FD,stroke:#1976D2 style Mixins fill:#FFF3E0,stroke:#F57C00 style Decorators fill:#E8F5E9,stroke:#388E3C @@ -65,21 +65,21 @@ classDiagram +get_service(name) Any +call_plugin(name, action, payload) Future } - + class AutoDispatchMixin { +async handle(action, payload) dict #_find_action_handler(action) Method } - + class RoutedPlugin { #_routes: List[Route] #discover_routes() None #get_routes() List[Route] } - + TrustedBase <|-- AutoDispatchMixin TrustedBase <|-- RoutedPlugin - + note for TrustedBase "Base class pour plugins trusted" note for AutoDispatchMixin "Routage automatique via @action" note for RoutedPlugin "Support des routes HTTP" @@ -98,9 +98,9 @@ sequenceDiagram S->>L: on_load() Note over L: Initialiser services
Subscrire événements L-->>S: ✅ Ready - + Note over P: Plugin opérationnel... - + S->>U: on_unload() Note over U: Cleanup ressources
Unsubscribe événements U-->>S: ✅ Unloaded @@ -122,32 +122,32 @@ class Plugin(AutoDispatchMixin, TrustedBase): """ Plugin de démonstration montrant toutes les fonctionnalités de TrustedBase. """ - + async def on_load(self) -> None: """ 🎯 Point d'entrée au chargement du plugin. - + Initialiser ici : - Les services (db, cache, scheduler) - Les subscriptions d'événements - L'état interne """ self.logger.info(f"🚀 Chargement de {self.name} v{self.version}") - + # Initialiser les services self.db = self.get_service("db") self.cache = self.get_service("cache") self.scheduler = self.get_service("scheduler") - + # S'abonner à un événement self.ctx.events.on("user.created", self._on_user_created, priority=50) - + self.logger.info("✅ Plugin prêt") - + async def on_unload(self) -> None: """ 🧹 Point de cleanup au déchargement. - + Nettoyer ici : - Connexions ouvertes - Subscriptions événements @@ -155,24 +155,24 @@ class Plugin(AutoDispatchMixin, TrustedBase): """ self.logger.info(f"👋 Déchargement de {self.name}") # Cleanup si nécessaire - + async def on_reload(self) -> None: """ 🔄 Appelé lors d'un hot-reload. - + Re-initialiser l'état sans perdre les connexions. """ self.logger.info(f"🔄 Rechargement de {self.name}") await self.on_load() # Ré-initialiser - + @action("greet") async def greet(self, payload: dict) -> dict: """ 🎯 Action: Saluer un utilisateur. - + Args: payload: {"name": "Votre nom", "language": "fr"} - + Returns: {"status": "ok", "message": "Bonjour, X !"} """ @@ -181,7 +181,7 @@ class Plugin(AutoDispatchMixin, TrustedBase): input_data = GreetInput(**payload) except ValidationError as e: return error(msg=str(e), code="invalid_input", status_code=400) - + # Logique métier greetings = { "fr": "Bonjour", @@ -190,10 +190,10 @@ class Plugin(AutoDispatchMixin, TrustedBase): } greeting = greetings.get(input_data.language, "Hello") message = f"{greeting}, {input_data.name} !" - + self.logger.info(f"Salutation: {message}") return ok(message=message) - + @on("user.created") async def _on_user_created(self, event: Event) -> None: """Handler pour l'événement user.created.""" @@ -214,15 +214,15 @@ class CustomPluginRunner(BasePlugin): """ Implémentation personnalisée d'un runner de plugin. """ - + async def initialize(self, config: dict) -> None: """Initialisation personnalisée.""" pass - + async def execute(self, action: str, payload: dict) -> dict: """Exécution personnalisée.""" return {"status": "ok", "result": "custom"} - + async def cleanup(self) -> None: """Cleanup personnalisé.""" pass @@ -243,7 +243,7 @@ flowchart LR C -->|Trouvé| D[Exécuter handler] C -->|Non trouvé| E[❌ Error 404] D --> F[Retourner résultat] - + style B fill:#E3F2FD,stroke:#1976D2 style D fill:#E8F5E9,stroke:#388E3C style E fill:#FFEBEE,stroke:#C62828 @@ -253,37 +253,37 @@ flowchart LR from xcore.sdk import TrustedBase, AutoDispatchMixin, action, ok class Plugin(AutoDispatchMixin, TrustedBase): - + @action("ping") async def ping(self, payload: dict) -> dict: """Health check simple.""" return ok(status="pong", timestamp=self._now()) - + @action("add") async def add(self, payload: dict) -> dict: """Additionne deux nombres.""" a = payload.get("a", 0) b = payload.get("b", 0) return ok(result=a + b) - + @action("complex_operation") async def complex_op(self, payload: dict) -> dict: """Opération avec validation et services.""" # Accéder au service db = self.get_service("db") - + # Vérifier le cache cache_key = f"op:{payload.get('id')}" cached = await self.cache.get(cache_key) if cached: return ok(result=cached, source="cache") - + # Exécuter l'opération result = await self._do_operation(payload) - + # Caster le résultat await self.cache.set(cache_key, result, ttl=300) - + return ok(result=result, source="computed") ``` @@ -300,16 +300,16 @@ flowchart TB R2[@route("/users/{id}", GET)] R3[@route("/users", POST)] end - + subgraph Mount["📡 FastAPI Mount"] M1[/plugin/my_plugin/users] M2[/plugin/my_plugin/users/{id}] end - + R1 --> M1 R2 --> M2 R3 --> M1 - + style Plugin fill:#FFF3E0,stroke:#F57C00 style Mount fill:#E3F2FD,stroke:#1976D2 ``` @@ -345,12 +345,12 @@ class UserResponse(BaseModel): class Plugin(RoutedPlugin, TrustedBase): """Plugin avec routes HTTP complètes.""" - + @route("/users", method="GET", tags=["users"], summary="Lister les utilisateurs") async def list_users(self, limit: int = 100, offset: int = 0) -> dict: """ Liste tous les utilisateurs avec pagination. - + - **limit**: Nombre maximum d'utilisateurs - **offset**: Décalage pour la pagination """ @@ -359,9 +359,9 @@ class Plugin(RoutedPlugin, TrustedBase): query = f"SELECT * FROM users LIMIT {limit} OFFSET {offset}" result = await session.execute(query) users = result.fetchall() - + return ok(users=[dict(u) for u in users], total=len(users)) - + @route("/users/{user_id}", method="GET", tags=["users"]) async def get_user(self, user_id: int) -> dict: """Récupérer un utilisateur par ID.""" @@ -370,31 +370,31 @@ class Plugin(RoutedPlugin, TrustedBase): query = "SELECT * FROM users WHERE id = :id" result = await session.execute(query, {"id": user_id}) user = result.fetchone() - + if not user: return error(msg="Utilisateur non trouvé", code="not_found", status_code=404) - + return ok(user=dict(user)) - + @route("/users", method="POST", tags=["users"], status_code=201) async def create_user(self, data: UserCreate) -> dict: """Créer un nouvel utilisateur.""" db = self.get_service("db") - + async with db.session() as session: query = "INSERT INTO users (email, name) VALUES (:email, :name) RETURNING *" result = await session.execute(query, dict(data)) user = result.fetchone() await session.commit() - + # Émettre un événement await self.ctx.events.emit("user.created", { "id": user.id, "email": user.email }) - + return ok(user=dict(user)) - + @route("/health", method="GET", tags=["system"]) async def health_check(self) -> dict: """Endpoint de santé du plugin.""" @@ -417,11 +417,11 @@ flowchart TB B --> C{Validation} C -->|✅ Valide| D[Handler exécuté] C -->|❌ Invalide| E[Erreur 400] - + D --> F{type_response ?} F -->|dict| G[Dict validé] F -->|pydantic| H[Instance Pydantic] - + style B fill:#E3F2FD,stroke:#1976D2 style E fill:#FFEBEE,stroke:#C62828 ``` @@ -439,7 +439,7 @@ class CreateUserInput(BaseModel): age: int = Field(ge=0, le=150) class Plugin(AutoDispatchMixin, TrustedBase): - + # Mode 1: Retourne un dict validé @action("create_user_dict") @validate_payload(CreateUserInput, type_response="dict") @@ -448,7 +448,7 @@ class Plugin(AutoDispatchMixin, TrustedBase): email = payload["email"] name = payload["name"] return ok(email=email, name=name) - + # Mode 2: Retourne l'instance Pydantic @action("create_user_model") @validate_payload(CreateUserInput, type_response="pydantic") @@ -470,13 +470,13 @@ Markers pour s'assurer qu'une méthode s'exécute uniquement dans le mode spéci from xcore.sdk import TrustedBase, trusted, sandboxed, action class Plugin(TrustedBase): - + @action("trusted_only") @trusted async def trusted_operation(self, payload: dict) -> dict: """Ne s'exécute que en mode trusted.""" return ok(result="trusted") - + @action("sandboxed_only") @sandboxed async def sandboxed_operation(self, payload: dict) -> dict: @@ -494,12 +494,12 @@ Subscribe un handler à un événement. from xcore.sdk import TrustedBase, on, Event class Plugin(TrustedBase): - + @on("user.created", priority=100) async def handle_user_created(self, event: Event): """Handler avec haute priorité.""" pass - + @on("order.completed", priority=50) async def handle_order_completed(self, event: Event): """Handler avec priorité normale.""" @@ -516,12 +516,12 @@ Enregistre un hook filter ou action. from xcore.sdk import TrustedBase, filter, action_hook class Plugin(TrustedBase): - + @filter("page_title") def modify_title(self, title: str) -> str: """Filter: modifie le titre de page.""" return f"{title} | Mon App" - + @action_hook("before_render") def before_render_log(self, template: str): """Action hook: log avant rendu.""" @@ -545,7 +545,7 @@ flowchart TB D -->|Non| F[Erreur: Action inconnue] E --> G[Retourner résultat] F --> H[Retourner error()] - + style B fill:#E3F2FD,stroke:#1976D2 style E fill:#E8F5E9,stroke:#388E3C style F fill:#FFEBEE,stroke:#C62828 @@ -564,11 +564,11 @@ class Plugin(TrustedBase): # Avec AutoDispatchMixin (concis) class Plugin(AutoDispatchMixin, TrustedBase): - + @action("ping") async def ping(self, payload: dict) -> dict: return ok(status="pong") - + @action("greet") async def greet(self, payload: dict) -> dict: return ok(message=f"Hello !") @@ -585,11 +585,11 @@ from xcore.sdk import TrustedBase, RoutedPlugin, route class Plugin(RoutedPlugin, TrustedBase): """Les routes sont automatiquement montées sur /plugin/{name}/...""" - + @route("/hello", methods=["GET"]) async def hello(self): return {"message": "Hello"} - + @route("/hello/{name}", methods=["GET"]) async def hello_name(self, name: str): return {"message": f"Hello, {name}!"} @@ -662,20 +662,20 @@ class User(BaseModel): class UserRepository(BaseAsyncRepository): """Repository pour les opérations sur les utilisateurs.""" - + async def get_by_email(self, email: str) -> Optional[User]: """Récupérer un utilisateur par email.""" query = "SELECT * FROM users WHERE email = :email" result = await self.session.execute(query, {"email": email}) row = result.fetchone() return User(**dict(row)) if row else None - + async def list_all(self, limit: int = 100) -> List[User]: """Lister tous les utilisateurs.""" query = "SELECT * FROM users LIMIT :limit" result = await self.session.execute(query, {"limit": limit}) return [User(**dict(row)) for row in result.fetchall()] - + async def create(self, data: dict) -> User: """Créer un nouvel utilisateur.""" query = "INSERT INTO users (email, name) VALUES (:email, :name) RETURNING *" diff --git a/doc/requirements.txt b/doc/requirements.txt index 6d519fc9..a28673ed 100644 --- a/doc/requirements.txt +++ b/doc/requirements.txt @@ -1,4 +1,4 @@ mkdocs ==1.6.1 mkdocs-material ==9.7.3 mkdocs-minify-plugin ==0.8.0 -mkdocstrings ==1.0.3 \ No newline at end of file +mkdocstrings ==1.0.3 diff --git a/docgen/aggregator.py b/docgen/aggregator.py index dc7d6492..e7de6e77 100644 --- a/docgen/aggregator.py +++ b/docgen/aggregator.py @@ -8,8 +8,7 @@ def summarize_by_file(chunks, cfg): Groupe les chunks par fichier (path) et génère un résumé par chunk. Retourne un dict : path -> liste de résumés partiels + métadonnées du fichier. """ - file_map = defaultdict( - lambda: {"summaries": [], "module": None, "filename": None}) + file_map = defaultdict(lambda: {"summaries": [], "module": None, "filename": None}) for chunk in chunks: path = chunk["path"] diff --git a/docgen/cache/summaries.json b/docgen/cache/summaries.json index 8cf88a6a..0544c99f 100644 --- a/docgen/cache/summaries.json +++ b/docgen/cache/summaries.json @@ -131,4 +131,4 @@ "/home/eliezer/devs/xcore/xcore/services/extensions/loader.py:7f4a81f2869b3fe7ce19c76f684d057165ef051496fb6248233785bc819a6087": "### Technical Summary\n\n**Purpose**: The `loader.py` file in the `/home/eliezer/devs/xcore/xcore/services/extensions/` directory is responsible for loading and managing external services defined in the `xcore.yaml` configuration. It allows developers to declare custom services that inherit from a base class or expose specific methods like `init()`, `shutdown()`, `health_check()`, and `status()`.\n\n**Key Components**:\n- **ExtensionLoader Class**: Inherits from `BaseService`. Manages the loading, initialization, shutdown, health check, and status of extensions.\n- **load Method**: Imports a module based on the provided path and class name. It also handles configuration settings for each service.\n- **init Method**: Initializes all loaded services. It logs success or failure for each extension.\n- **shutdown Method**: Shuts down all loaded services. It logs any errors that occur during shutdown.\n- **health_check Method**: Checks the health of all loaded services and returns a tuple indicating whether all extensions are healthy and their status messages.\n\n**Dependencies**:\n- The file imports `importlib` for dynamic module loading and `logging` for logging purposes.\n- It relies on the `BaseService` class from the same directory, which provides common functionality for service management.\n\n**Interactions**:\n- The `ExtensionLoader` class is called by the main application to load and manage services defined in the configuration file. It interacts with other parts of the codebase through methods like `init()`, `shutdown()`, and `health_check()`.\n\nThis file plays a crucial role in extending the functionality of xcore by allowing developers to integrate third-party services seamlessly into their applications.", "/home/eliezer/devs/xcore/xcore/services/scheduler/__init__.py:a9b8cda273322bd5623b8f9ccd01e3decfbbba3bff8516066ef408dd686377cb": "### Technical Summary of `/home/eliezer/devs/xcore/xcore/services/scheduler/__init__.py`\n\n#### Purpose\nThis file serves as the entry point for the scheduler service within the XCore project. It defines the main class `SchedulerService` which is responsible for managing and executing scheduled tasks.\n\n#### Key Components\n- **Main Class**: `SchedulerService`\n - This class encapsulates all functionalities related to scheduling tasks.\n - It provides methods for adding, removing, and executing scheduled jobs.\n - The class interacts with other components like task queues and database services.\n\n#### Dependencies\n- **Internal Modules**:\n - `service`: Contains the implementation of the `SchedulerService`.\n- **External Libraries**:\n - None\n\n#### Interactions\n- **Called by**: Other parts of the XCore system that need to manage scheduled tasks.\n- **Calls into**: The `SchedulerService` class provides methods for interacting with task queues and database services.\n- **Data Produced or Consumed**: The `SchedulerService` manages a queue of scheduled jobs, which are then processed by other components. It also interacts with a database to store and retrieve job details.\n\nThis file is crucial for orchestrating the execution of tasks in a timely manner within the XCore system.", "/home/eliezer/devs/xcore/xcore/services/scheduler/service.py:2cd739a019b675930bca98d913224c1d83ea12ab5e9eba24350358ab596c93ff": "### Technical Summary\n\n**Purpose**: The `service.py` file in the `/home/eliezer/devs/xcore/xcore/services/scheduler/` directory is responsible for managing a task scheduler using APScheduler. This scheduler supports cron, interval, and date triggers, with options to store jobs in memory or Redis.\n\n**Key components**:\n- **SchedulerService**: The main class that initializes the scheduler, manages job scheduling, and handles dependencies.\n- **MemoryJobStore**: Manages jobs stored in memory for simplicity.\n- **RedisJobStore**: Manages jobs stored in a Redis database if configured.\n- **AsyncIOScheduler**: Asynchronous scheduler using asyncio.\n\n**Dependencies**:\n- `apscheduler`: Required for the task scheduler functionality.\n- `redis` (if enabled): For storing job data in Redis.\n- `configurations.sections`: Configuration settings related to the scheduler.\n\n**Interactions**:\n- The scheduler is initialized with a configuration file that specifies job details such as cron expressions, trigger types, and job IDs.\n- Jobs are added using decorators or directly through the `add_job` method.\n- The scheduler can be started, stopped, paused, resumed, and queried for jobs.\n- Health checks provide information about the scheduler's status and running state.\n\nThis file is crucial for managing background tasks in a Python application, ensuring that critical operations are executed at specified intervals or on specific dates." -} \ No newline at end of file +} diff --git a/docgen/pipeline.py b/docgen/pipeline.py index ad3cd132..cdd5c89d 100644 --- a/docgen/pipeline.py +++ b/docgen/pipeline.py @@ -95,8 +95,7 @@ def main(): all_modules = set(modules_files.keys()) | set(modules_children.keys()) # Trier du plus profond (plus de "/" dans le chemin) au plus haut - sorted_modules = sorted( - all_modules, key=lambda m: m.count("/"), reverse=True) + sorted_modules = sorted(all_modules, key=lambda m: m.count("/"), reverse=True) # Stocker une description courte par module (pour les index parents) module_descriptions = {} diff --git a/docgen/scanner.py b/docgen/scanner.py index 4126203f..96cb659c 100644 --- a/docgen/scanner.py +++ b/docgen/scanner.py @@ -9,7 +9,7 @@ def load_config(): def split_content(content, max_chars): - return [content[i: i + max_chars] for i in range(0, len(content), max_chars)] + return [content[i : i + max_chars] for i in range(0, len(content), max_chars)] def is_excluded(root, exclude_dirs): diff --git a/docs-tech/architecture/overview.md b/docs-tech/architecture/overview.md index dc84ee37..06eecf25 100644 --- a/docs-tech/architecture/overview.md +++ b/docs-tech/architecture/overview.md @@ -18,39 +18,39 @@ flowchart TB EB[📡 Event Bus
Messagerie async] PE[🛡️ Permission Engine
Contrôle d'accès] end - + subgraph Infra["🏗️ Infrastructure Partagée"] DB[(🗄️ Database
SQL/NoSQL)] CACHE[(⚡ Cache
Redis/Memory)] SCHED[⏰ Scheduler
APScheduler] LOG[📝 Logger
Structuré] end - + subgraph Plugins["🧩 Écosystème Plugins"] direction LR TP[🔐 Trusted Plugins
Processus principal] SP[📦 Sandboxed Plugins
Processus isolé] end - + subgraph App["🌐 Application"] FA[FastAPI
Routes HTTP] WS[WebSocket
Real-time] end - + App --> X X --> PS X --> SC X --> EB X --> PE - + SC --> Infra PS --> TP PS --> SP - + EB -.->|Pub/Sub| TP EB -.->|Pub/Sub| SP PE -.->|Audit| PS - + style Core fill:#E3F2FD,stroke:#1976D2,stroke-width:2px style Infra fill:#E8F5E9,stroke:#388E3C,stroke-width:2px style Plugins fill:#FFF3E0,stroke:#F57C00,stroke-width:2px @@ -87,37 +87,37 @@ sequenceDiagram Dev->>Main: uvicorn app:app Main->>X: boot(app) - + Note over X: 📋 Phase 1: Configuration X->>Cfg: Charger xcore.yaml X->>Cfg: Charger .env Cfg-->>X: Config validée - + Note over X: 🔧 Phase 2: Services X->>S: Initialiser DB X->>S: Initialiser Cache X->>S: Initialiser Scheduler S-->>X: ✅ Services Ready - + Note over X: 🧩 Phase 3: Plugins X->>L: Découvrir plugins/ L->>L: Parser plugin.yaml L->>DAG: Build dependency graph DAG->>DAG: Topological sort DAG-->>L: Ordre de chargement - + loop Chaque vague de plugins L->>P: Load plugin (trusted/sandboxed) P->>P: on_load() P-->>L: ✅ Ready end - + Note over X: 🌐 Phase 4: Routage X->>R: Monter routes FastAPI R->>R: Plugin routes R->>R: API routes R-->>X: ✅ Routes montées - + X-->>Main: Application prête Main-->>Dev: 🚀 Running on :8000 ``` @@ -129,23 +129,23 @@ gantt title Boot Sequence Timeline dateFormat X axisFormat %L - + section Configuration Charger YAML :a1, 0, 10 Charger ENV :a2, after a1, 5 Validation :a3, after a2, 5 - + section Services Database :b1, after a3, 20 Cache :b2, after a3, 15 Scheduler :b3, after a3, 10 - + section Plugins Discovery :c1, after b1, 10 DAG Build :c2, after c1, 15 Load Wave 1 :c3, after c2, 30 Load Wave 2 :c4, after c3, 30 - + section Routing Mount Routes :d1, after c4, 10 ``` @@ -159,9 +159,9 @@ Chaque plugin est géré par une **Finite State Machine (FSM)** pour assurer des ```mermaid stateDiagram-v2 [*] --> UNLOADED: Découverte - + UNLOADED --> LOADING: supervisor.load() - + state LOADING { [*] --> SCANNING: AST Scan SCANNING --> VALIDATING: Scan OK @@ -169,40 +169,40 @@ stateDiagram-v2 INSTANTIATING --> INITIALIZING: Entry point OK INITIALIZING --> READY: on_load() OK end - + LOADING --> FAILED: Erreur AST LOADING --> FAILED: Erreur Manifest LOADING --> FAILED: Erreur on_load() - + READY --> RELOADING: supervisor.reload() - + state RELOADING { [*] --> UNLOADING: on_unload() UNLOADING --> RELOADING_CODE: Recharger code RELOADING_CODE --> RE_INITIALIZING: Nouveau on_load() RE_INITIALIZING --> READY: Succès end - + RELOADING --> FAILED: Échec reload - + READY --> UNLOADED: supervisor.unload() FAILED --> UNLOADED: Cleanup FAILED --> RELOADING: Retry (max 3) - + note right of READY 🟢 État stable - Routes actives - Événements abonnés - Services connectés end note - + note right of FAILED 🔴 État terminal - Erreur enregistrée - Cleanup requis - Notification envoyée end note - + note right of UNLOADED ⚪ État initial/final - Code non chargé @@ -252,7 +252,7 @@ flowchart LR M3 --> M4 M4 -- "execute" --> B B -- "result" --> A - + style Kernel fill:#FFEBEE,stroke:#C62828,stroke-width:2px style M3 fill:#C62828,color:#fff ``` @@ -269,20 +269,20 @@ sequenceDiagram participant B as Plugin B A->>T: call("plugin_b", "action", payload) - + Note over T: 1. Tracing T->>T: Créer span ID T->>T: Injecter context - + Note over R: 2. Rate Limiting R->>R: Vérifier quota A R-->>T: ✅ Dans la limite - + Note over P: 3. Permission Audit P->>P: Charger policies P->>P: Vérifier A → B.action P-->>T: ✅ Autorisé - + Note over L: 4. Retry Logic L->>B: Exécuter action alt Succès @@ -321,24 +321,24 @@ flowchart TB PS[PluginSupervisor] WR[Worker Manager] end - + subgraph IPC["📡 JSON-RPC 2.0 Channel"] direction LR PIPE1[Pipe stdout] PIPE2[Pipe stdin] end - + subgraph Sandbox["📦 Sandbox Process"] SW[Sandbox Worker] AST[🛡️ AST Scanner] EX[Code Exécuté] end - + K --> PS PS --> WR WR --> IPC IPC --> Sandbox - + style Sandbox fill:#FFEBEE,stroke:#C62828 style AST fill:#C62828,color:#fff ``` @@ -351,19 +351,19 @@ flowchart LR A1[Parser AST] --> A2[Forbidden Names] A2 --> A3[Import Whitelist] end - + subgraph L2["Couche 2: Process Isolation"] B1[Processus dédié] --> B2[JSON-RPC sur pipes] B2 --> B3[Pas de pickle] end - + subgraph L3["Couche 3: Resource Limits"] C1[Memory Monitor] --> C2[Timeout Monitor] C2 --> C3[Kill si dépassement] end - + L1 --> L2 --> L3 - + style L1 fill:#FFE0B2,stroke:#F57C00 style L2 fill:#C8E6C9,stroke:#388E3C style L3 fill:#BBDEFB,stroke:#1976D2 @@ -422,23 +422,23 @@ flowchart TB PRI[🔴 Private Services
Kernel interne] SCO[🟡 Scoped Services
Plugins autorisés] end - + subgraph Access["Accès"] K[🎯 Kernel] T[🔐 Trusted Plugins] S[📦 Sandboxed Plugins] end - + PUB --> K PUB --> T PUB --> S - + PRI --> K - + SCO --> K SCO --> T SCO -.->|Si autorisé| S - + style PUB fill:#C8E6C9,stroke:#388E3C style PRI fill:#FFCDD2,stroke:#C62828 style SCO fill:#FFF9C4,stroke:#F57F17 @@ -462,7 +462,7 @@ services: scope: public backend: postgresql url: ${DATABASE_URL} - + # Service scoped - accessible uniquement par authorized plugins payment_db: scope: scoped @@ -471,7 +471,7 @@ services: - billing_plugin backend: postgresql url: ${PAYMENT_DATABASE_URL} - + # Service private - kernel uniquement audit_log: scope: private @@ -496,23 +496,23 @@ sequenceDiagram Client->>API: POST /plugins/call API->>X: plugins.call(plugin, action, payload) - + Note over X,P: 1. Permission Check X->>P: verify(plugin, action) P-->>X: ✅ Autorisé - + Note over X,S: 2. Resolve Plugin X->>S: get_handler(plugin, action) S-->>X: Handler trouvé - + Note over S,PL: 3. Execute Action S->>PL: @action(payload) - + alt Besoin DB PL->>DB: Query DB-->>PL: Résultats end - + PL-->>S: Résultat S-->>X: Response X-->>API: JSON diff --git a/docs-tech/getting-started/quickstart.md b/docs-tech/getting-started/quickstart.md index d7c7f33e..57a27602 100644 --- a/docs-tech/getting-started/quickstart.md +++ b/docs-tech/getting-started/quickstart.md @@ -11,7 +11,7 @@ flowchart LR A[1. Initialiser
l'Application] --> B[2. Créer
le Plugin] B --> C[3. Démarrer
le Serveur] C --> D[4. Tester
l'Action] - + style A fill:#4CAF50,color:#fff style B fill:#2196F3,color:#fff style C fill:#FF9800,color:#fff @@ -60,14 +60,14 @@ sequenceDiagram You->>App: uvicorn app:app App->>Kernel: boot() - + Note over Kernel: Initialisation... Kernel->>Services: DB, Cache, Scheduler Kernel->>Plugins: Chargement - + Services-->>Kernel: ✅ Prêts Plugins-->>Kernel: ✅ Chargés - + Kernel-->>App: ✅ Prêt App-->>You: 🚀 Serveur sur :8000 ``` @@ -130,30 +130,30 @@ class Plugin(AutoDispatchMixin, TrustedBase): """ Plugin de démonstration qui salue les utilisateurs. """ - + async def on_load(self): """🎯 Appelé quand le plugin est chargé.""" self.logger.info("✨ Hello plugin est prêt !") - + async def on_unload(self): """🧹 Appelé quand le plugin est déchargé.""" self.logger.info("👋 Hello plugin est déchargé") - + @action("greet") async def greet_user(self, payload: dict): """ 🎯 Action: greet - + Args: payload: {"name": "Votre nom"} - + Returns: {"status": "ok", "message": "Hello, X !"} """ name = payload.get("name", "World") self.logger.info(f"Salutation de: {name}") return ok(message=f"Hello, {name} !") - + @action("ping") async def ping(self, payload: dict): """Test de connectivité.""" @@ -216,14 +216,14 @@ sequenceDiagram CLI->>API: POST /plugins/call API->>Kernel: plugins.call("hello_plugin", "greet") - + Kernel->>Perm: Vérifier permissions Perm-->>Kernel: ✅ Autorisé - + Kernel->>Plugin: @action("greet") Plugin->>Plugin: greet_user(payload) Plugin-->>Kernel: {"status": "ok", ...} - + Kernel-->>API: Résultat API-->>CLI: Response JSON ``` @@ -238,12 +238,12 @@ sequenceDiagram from xcore.sdk import TrustedBase, RoutedPlugin, route class Plugin(RoutedPlugin, TrustedBase): - + @route("/hello/{name}", methods=["GET"]) async def hello_api(self, name: str): """Route HTTP accessible directement.""" return {"message": f"Hello, {name} !"} - + @route("/status", methods=["GET"]) async def status(self): """Endpoint de santé du plugin.""" @@ -264,24 +264,24 @@ class Plugin(RoutedPlugin, TrustedBase): from xcore.sdk import TrustedBase, AutoDispatchMixin, action, ok class Plugin(AutoDispatchMixin, TrustedBase): - + async def on_load(self): self.cache = self.get_service("cache") - + @action("greet_cached") async def greet_cached(self, payload: dict): name = payload.get("name", "World") cache_key = f"greeting:{name}" - + # Vérifier le cache cached = await self.cache.get(cache_key) if cached: return ok(message=cached, source="cache") - + # Générer et caster message = f"Hello, {name} !" await self.cache.set(cache_key, message, ttl=60) - + return ok(message=message, source="generated") ``` @@ -291,17 +291,17 @@ class Plugin(AutoDispatchMixin, TrustedBase): from xcore.sdk import TrustedBase, AutoDispatchMixin, action, ok class Plugin(AutoDispatchMixin, TrustedBase): - + @action("create_user") async def create_user(self, payload: dict): # ... logique de création ... - + # 📡 Émettre un événement await self.ctx.events.emit("user.created", { "id": user_id, "email": payload.get("email") }) - + return ok(user_id=user_id) ``` diff --git a/docs-tech/guides/events.md b/docs-tech/guides/events.md index b3cee715..494b0dca 100644 --- a/docs-tech/guides/events.md +++ b/docs-tech/guides/events.md @@ -14,17 +14,17 @@ flowchart LR E3[Pas de retour] E4[Side effects] end - + subgraph Hooks["🪝 Hooks (Sync)"] H1[Transformation] H2[Séquentiel] H3[Retour valeur] H4[Inline logic] end - + Events -.->|Découplage fort| Hooks Hooks -.->|Transformation| Events - + style Events fill:#E3F2FD,stroke:#1976D2 style Hooks fill:#FFF3E0,stroke:#F57C00 ``` @@ -44,28 +44,28 @@ flowchart TB P2[Plugin B] K[Kernel] end - + subgraph EventBus["📡 Event Bus"] direction TB Q[Queue Async] D[Dispatcher] R[Registry Handlers] end - + subgraph Subscriber["📥 Subscribers"] S1[Handler 1] S2[Handler 2] S3[Handler 3] end - + P1 --> EventBus P2 --> EventBus K --> EventBus - + EventBus --> S1 EventBus --> S2 EventBus --> S3 - + style EventBus fill:#E8F5E9,stroke:#388E3C ``` @@ -85,33 +85,33 @@ flowchart TB from xcore.sdk import TrustedBase, on, Event class Plugin(TrustedBase): - + async def on_load(self): # Méthode 1: Décorateur (recommandé) # Le handler est automatiquement abonné - + # Méthode 2: Subscription manuelle self.ctx.events.on("user.created", self._on_user_created, priority=100) - + @on("user.created", priority=100) async def _on_user_created(self, event: Event): """ Handler pour l'événement user.created - + Args: event: Contient data, timestamp, source """ user_id = event.data.get("id") email = event.data.get("email") - + self.logger.info(f"Nouvel utilisateur créé: {user_id}") - + # Side effect: envoyer email de bienvenue await self._send_welcome_email(email) - + # Optionnel: stopper la propagation # event.stop_propagation() - + async def _send_welcome_email(self, email: str): # ... logique d'envoi ... pass @@ -123,19 +123,19 @@ class Plugin(TrustedBase): from xcore.sdk import TrustedBase, action, ok class Plugin(TrustedBase): - + @action("create_user") async def create_user(self, payload: dict): # 1. Créer l'utilisateur user_id = await self._create_in_db(payload) - + # 2. Émettre l'événement await self.ctx.events.emit("user.created", { "id": user_id, "email": payload.get("email"), "created_at": self._now() }) - + # 3. Retourner résultat (l'événement est fire-and-forget) return ok(user_id=user_id) ``` @@ -151,21 +151,21 @@ sequenceDiagram participant H3 as Handler 3 (priority=50) P->>EB: emit("user.created", data) - + Note over EB: Registry handlers par priority EB->>H1: _on_user_created(event) - + par Exécution parallèle (asyncio.gather) EB->>H2: handler_2(event) EB->>H3: handler_3(event) end - + H1-->>EB: ✅ Done H2-->>EB: ✅ Done H3-->>EB: ✅ Done - + EB-->>P: ✅ Tous handlers exécutés - + Note over P,H3: ⚠️ Le publisher n'attend pas
les résultats des handlers ``` @@ -194,20 +194,20 @@ XCore émet plusieurs événements système que vous pouvez subscriber. from xcore.sdk import TrustedBase, on, Event class Plugin(TrustedBase): - + @on("xcore.plugins.booted", priority=200) async def _on_system_boot(self, event: Event): """Exécuté quand TOUS les plugins sont chargés.""" report = event.data.get("report", {}) loaded_count = report.get("loaded", 0) self.logger.info(f"🚀 Système prêt : {loaded_count} plugins chargés") - + @on("plugin.auth_plugin.loaded", priority=100) async def _on_auth_ready(self, event: Event): """Exécuté quand le plugin auth est prêt.""" self.logger.info("✅ Plugin auth disponible") # Initialiser la connexion avec le plugin auth - + @on("permission.deny", priority=50) async def _on_permission_denied(self, event: Event): """Logging de toutes les permissions refusées.""" @@ -228,16 +228,16 @@ flowchart TB D3[Cleanup handlers] D4[Événements nommés] end - + subgraph Dont["❌ Erreurs à Éviter"] A1[Tâches lourdes] A2[Priorités magiques] A3[Memory leaks] A4[Noms obscurs] end - + Do -.->|Au lieu de| Dont - + style Do fill:#E8F5E9,stroke:#388E3C style Dont fill:#FFEBEE,stroke:#C62828 ``` @@ -250,7 +250,7 @@ flowchart TB async def bad_handler(self, event): # Envoi email synchrone (lent !) send_email_sync(event.data["email"]) - + # Traitement lourd for i in range(1000000): process(i) @@ -300,7 +300,7 @@ await self.ctx.events.emit("order.completed", data) from xcore.sdk import TrustedBase, on, Event class Plugin(TrustedBase): - + @on("user.created", priority=100) async def validate_user(self, event: Event): """Validation en premier, peut bloquer les autres handlers.""" @@ -308,14 +308,14 @@ class Plugin(TrustedBase): self.logger.error("Utilisateur invalide") event.stop_propagation() # ⚠️ Stop les handlers suivants return - + @on("user.created", priority=50) async def send_welcome(self, event: Event): """Ne s'exécute que si validation passe.""" if event.propagation_stopped: self.logger.debug("Propagation stoppée, skip welcome") return - + # Envoyer email... ``` @@ -335,14 +335,14 @@ flowchart LR F3 --> F4[Hook 3] F4 --> F5[Modified Value] end - + subgraph Actions["⚡ Actions (Side Effects)"] A1[Trigger] --> A2[Hook 1] A2 --> A3[Hook 2] A3 --> A4[Hook 3] A4 --> A5[Done] end - + style Filters fill:#E3F2FD,stroke:#1976D2 style Actions fill:#FFF3E0,stroke:#F57C00 ``` @@ -357,7 +357,7 @@ flowchart LR H1 -->|Modifie| H2[Hook 2] H2 -->|Modifie| H3[Hook 3] H3 -->|Modifie| R[Valeur Finale] - + style V fill:#E8F5E9 style R fill:#C8E6C9 ``` @@ -365,19 +365,19 @@ flowchart LR ```python # Côté Kernel ou Plugin class Plugin(TrustedBase): - + async def render_page(self): # 1. Valeur de base title = "Bienvenue sur XCore" - + # 2. Appliquer les filters title = self.ctx.hooks.apply_filters("page_title", title) - + # title peut maintenant être modifié par d'autres plugins # Ex: "Bienvenue sur XCore | Dashboard" - + return f"

{title}

" - + # 3. Enregistrer un filter @filter("page_title") def modify_title(self, title: str) -> str: @@ -392,19 +392,19 @@ Les actions sont des side effects synchrones qui ne retournent pas de valeur. ```python # Côté Kernel ou Plugin class Plugin(TrustedBase): - + async def render_template(self, template_name: str): # 1. Trigger action avant rendu self.ctx.hooks.do_action("before_render", template=template_name) - + # 2. Rendu du template result = self._render(template_name) - + # 3. Trigger action après rendu self.ctx.hooks.do_action("after_render", template=template_name, result=result) - + return result - + # 4. Enregistrer un action hook @action_hook("before_render") def on_before_render(self, template: str): @@ -430,19 +430,19 @@ class Plugin(TrustedBase): ```mermaid flowchart TB Start{Besoin ?} - + Start -->|Modifier données| Hooks Start -->|Side effect| Events - + Start -->|Synchrone requis| Hooks Start -->|Async OK| Events - + Start -->|Plusieurs listeners| Events Start -->|Chaîne transformation| Hooks - + Hooks --> H[🪝 Hooks] Events --> E[📡 Events] - + style H fill:#FFF3E0,stroke:#F57C00 style E fill:#E3F2FD,stroke:#1976D2 ``` @@ -482,29 +482,29 @@ class NotificationPlugin(TrustedBase): """ Plugin de notification qui démontre Events + Hooks. """ - + async def on_load(self): # S'abonner aux événements système self.ctx.events.on("user.created", self._on_user_created, priority=50) self.ctx.events.on("order.completed", self._on_order_completed, priority=50) - + @on("user.created") async def _on_user_created(self, event): """Envoyer email de bienvenue.""" email = event.data.get("email") await self._send_email(email, "Bienvenue !") - + @on("order.completed") async def _on_order_completed(self, event): """Envoyer confirmation de commande.""" email = event.data.get("email") await self._send_email(email, "Commande confirmée !") - + @filter("notification.message") def add_disclaimer(self, message: str) -> str: """Ajouter un disclaimer à tous les messages.""" return f"{message}\n\n-- Ceci est un message automatique" - + @action("send_notification") async def send_notification(self, payload: dict): """Action publique pour envoyer une notification.""" @@ -513,13 +513,13 @@ class NotificationPlugin(TrustedBase): "notification.message", payload.get("message", "") ) - + # Émettre un événement pour logging await self.ctx.events.emit("notification.sent", { "to": payload.get("to"), "message": message }) - + return ok(status="sent") ``` diff --git a/docs-tech/guides/security.md b/docs-tech/guides/security.md index ea66f1c1..3ba8354e 100644 --- a/docs-tech/guides/security.md +++ b/docs-tech/guides/security.md @@ -16,7 +16,7 @@ flowchart TB L4[🛡️ Couche 4: Permissions
Policy Engine] L5[🛡️ Couche 5: Signature
HMAC Verification] end - + subgraph Attaques["Attaques Bloquées"] A1[Code Injection] A2[Sandbox Escape] @@ -24,13 +24,13 @@ flowchart TB A4[Accès Non Autorisé] A5[Tampering] end - + L1 -.->|Bloque| A1 L2 -.->|Bloque| A2 L3 -.->|Bloque| A3 L4 -.->|Bloque| A4 L5 -.->|Bloque| A5 - + style Defense fill:#E8F5E9,stroke:#388E3C style L1 fill:#C8E6C9 style L2 fill:#C8E6C9 @@ -55,19 +55,19 @@ flowchart LR C -->|Non| D[❌ Rejeté] C -->|Oui| E[📦 Spawn Worker] end - + subgraph Exec["Exécution"] E --> F[📡 JSON-RPC IPC] F --> G[⏱️ Timeout Monitor] G --> H[🧠 Memory Monitor] end - + subgraph PostExec["Post-Exécution"] H --> I{Limites OK ?} I -->|Non| J[❌ Kill Worker] I -->|Oui| K[✅ Résultat] end - + style PreExec fill:#FFF3E0,stroke:#F57C00 style Exec fill:#E3F2FD,stroke:#1976D2 style PostExec fill:#E8F5E9,stroke:#388E3C @@ -84,23 +84,23 @@ flowchart TB subgraph AST["🔍 AST Scanner"] S[Code Source] --> P[Parser AST] P --> N[NodeVisitor] - + N --> F1[Forbidden Names] N --> F2[Forbidden Attributes] N --> F3[Import Whitelist] - + F1 --> B1[❌ eval, exec] F1 --> B2[❌ globals, locals] F1 --> B3[❌ __import__] - + F2 --> B4[❌ __class__] F2 --> B5[❌ __globals__] F2 --> B6[❌ __subclasses__] - + F3 --> B7[❌ os, subprocess] F3 --> B8[❌ socket, requests] end - + style AST fill:#FFEBEE,stroke:#C62828 style B1 fill:#FFCDD2 style B2 fill:#FFCDD2 @@ -165,21 +165,21 @@ sequenceDiagram participant K as 🎯 Kernel Process participant M as 📡 Multiprocessing Manager participant W as 📦 Sandbox Worker - + Note over K: Boot du kernel K->>M: spawn_worker(plugin_path) - + Note over M: Création processus M->>W: fork()/spawn() W->>W: Charger code plugin - + Note over K,W: Communication via pipes K->>W: {"jsonrpc":"2.0","method":"call",...} Note over K,W: JSON uniquement
⚠️ Pas de pickle ! - + W->>W: Exécuter handler W->>K: {"jsonrpc":"2.0","result":...} - + Note over K: Désérialiser résultat ``` @@ -237,23 +237,23 @@ flowchart LR TM[⏱️ Timeout Monitor
Execution Timer] CM[📈 CPU Monitor
CPU Time Tracker] end - + subgraph Limits["⚠️ Limites"] ML[Max: max_memory_mb] TL[Max: timeout_seconds] CL[Max: cpu_seconds] end - + subgraph Actions["🔥 Actions"] MA[⚠️ Warning] KA[❌ Kill Process] RA[📝 Log Violation] end - + MM --> ML --> MA --> KA TM --> TL --> KA CM --> CL --> KA - + style Monitors fill:#E3F2FD,stroke:#1976D2 style Limits fill:#FFF3E0,stroke:#F57C00 style Actions fill:#FFEBEE,stroke:#C62828 @@ -266,10 +266,10 @@ flowchart LR resources: # Mémoire maximale (Mo) max_memory_mb: 256 - + # Timeout d'exécution (secondes) timeout_seconds: 30 - + # Rate limiting rate_limit: calls: 1000 @@ -302,13 +302,13 @@ flowchart TB Allow[✅ ALLOW] Deny[❌ DENY] end - + Start --> CheckDeny CheckDeny -->|Oui| Deny CheckDeny -->|Non| CheckAllow CheckAllow -->|Oui| Allow CheckAllow -->|Non| DefaultDeny - + style Deny fill:#FFCDD2,stroke:#C62828 style Allow fill:#C8E6C9,stroke:#388E3C style DefaultDeny fill:#FFCDD2,stroke:#C62828 @@ -334,17 +334,17 @@ permissions: - resource: "db.users.*" actions: ["read"] effect: allow - + # ✅ Autorise lecture/écriture sur cache global - resource: "cache.global" actions: ["read", "write"] effect: allow - + # ✅ Autorise TOUS les actions sur une resource - resource: "logs.*" actions: ["*"] effect: allow - + # ❌ DENY explicite (priorité haute) - resource: "db.admin.*" actions: ["*"] @@ -360,26 +360,26 @@ permissions: - resource: "db.main.users" actions: ["read"] effect: allow - + - resource: "db.main.posts" actions: ["read"] effect: allow - + # Écriture sur cache - resource: "cache.global" actions: ["read", "write"] effect: allow - + # Accès scheduler - resource: "scheduler.jobs" actions: ["create", "delete"] effect: allow - + # Interdiction explicite (sécurité défense) - resource: "db.admin.*" actions: ["*"] effect: deny - + - resource: "kernel.*" actions: ["*"] effect: deny @@ -396,16 +396,16 @@ sequenceDiagram A->>S: call("plugin_b", "get_user") S->>P: evaluate("plugin_a", "plugin_b.get_user") - + P->>L: Charger policies de plugin_a L-->>P: Retourner rules - + P->>P: Vérifier DENY explicite Note over P: ❌ Aucun DENY trouvé - + P->>P: Vérifier ALLOW explicite Note over P: ✅ Rule trouvée ! - + P-->>S: ✅ Autorisé S->>A: Procéder à l'appel ``` @@ -424,16 +424,16 @@ flowchart TB D1[Fichiers Plugin] --> D2[Calcul HMAC-SHA256] D2 --> D3[Générer plugin.sig] end - + subgraph Verify["🔍 Vérification (Production)"] V1[Charger plugin.sig] --> V2[Re-calculer hashes] V2 --> V3{Match ?} V3 -->|Oui| V4[✅ Charger] V3 -->|Non| V5[❌ Rejeté] end - + Sign --> Verify - + style Sign fill:#E8F5E9,stroke:#388E3C style Verify fill:#FFEBEE,stroke:#C62828 ``` @@ -473,11 +473,11 @@ sequenceDiagram participant Sig as plugin.sig K->>S: verify_trusted("my_plugin") - + S->>Sig: Charger signature S->>F: Lire fichiers actuels S->>S: Calculer hashes actuels - + S->>S: Comparer avec plugin.sig alt Hashes correspondent S-->>K: ✅ Vérifié @@ -503,7 +503,7 @@ flowchart LR D4[Pas d'état local] D5[Signer en prod] end - + subgraph Dont["❌ À Éviter"] A1[Trusted sans besoin] A2[Permissions larges] @@ -511,9 +511,9 @@ flowchart LR A4[État dans le plugin] A5[Code non signé] end - + Do -.->|Au lieu de| Dont - + style Do fill:#E8F5E9,stroke:#388E3C style Dont fill:#FFEBEE,stroke:#C62828 ``` @@ -560,7 +560,7 @@ class GreetInput(BaseModel): language: str = Field(default="fr") class Plugin(AutoDispatchMixin, TrustedBase): - + @action("greet") async def greet(self, payload: dict): # Valider l'input @@ -568,7 +568,7 @@ class Plugin(AutoDispatchMixin, TrustedBase): validated = GreetInput(**payload) except ValidationError as e: return error(msg=str(e), code="invalid_input") - + return ok(message=f"Bonjour, {validated.name} !") ``` @@ -584,7 +584,7 @@ class Plugin(TrustedBase): class Plugin(TrustedBase): async def on_load(self): self.cache = self.get_service("cache") # ✅ Persistant - + async def store(self, key, value): await self.cache.set(key, value) # ✅ Sauvegardé ``` diff --git a/docs-tech/index.md b/docs-tech/index.md index 6b19e37f..beb079f6 100644 --- a/docs-tech/index.md +++ b/docs-tech/index.md @@ -44,7 +44,7 @@ flowchart LR direction LR M[Monolithe
Rigide] <--> MM[**Monolithe Modulaire**
XCore] <--> MS[Microservices
Complexes] end - + style MM fill:#4CAF50,color:#fff,stroke:#2E7D32 style M fill:#FFC107,color:#000 style MS fill:#FFC107,color:#000 @@ -96,15 +96,15 @@ flowchart TB X --> PS X --> EB X --> PE - + SC --> Services PS --> TP PS --> SP - + EB -.->|Événements| TP EB -.->|Événements| SP PE -.->|Permissions| PS - + style Kernel fill:#E3F2FD,stroke:#1976D2 style Plugins fill:#FFF3E0,stroke:#F57C00 style Services fill:#E8F5E9,stroke:#388E3C @@ -127,24 +127,24 @@ sequenceDiagram Dev->>App: Lance l'application App->>Kernel: boot(app) - + Note over Kernel: 1. Configuration Kernel->>Kernel: Charge xcore.yaml + .env - + Note over Kernel: 2. Services Kernel->>Services: Initialise DB, Cache, Scheduler Services-->>Kernel: Services prêts - + Note over Kernel: 3. Plugins Kernel->>Loader: Découvre les plugins Loader->>Loader: Build DAG de dépendances Loader->>Loader: Tri topologique - + loop Chargement par vagues Loader->>P: Load (Trusted/Sandboxed) P-->>Loader: Ready end - + Kernel->>App: Monte les routes plugins App-->>Dev: Serveur prêt ! ``` @@ -154,25 +154,25 @@ sequenceDiagram ```mermaid stateDiagram-v2 [*] --> UNLOADED: Plugin découvert - + UNLOADED --> LOADING: supervisor.load() LOADING --> READY: on_load() ✅ LOADING --> FAILED: Erreur ❌ - + READY --> RELOADING: supervisor.reload() RELOADING --> READY: Mise à jour ✅ RELOADING --> FAILED: Échec ❌ - + READY --> UNLOADED: supervisor.unload() FAILED --> UNLOADED: Cleanup FAILED --> RELOADING: Retry - + note right of READY Plugin opérationnel Routes montées Événements abonnés end note - + note right of FAILED Erreur de syntaxe Dépendance manquante @@ -208,7 +208,7 @@ flowchart LR M3 --> M4 M4 -- "exécute" --> B B -- "résultat" --> A - + style Supervisor fill:#FFEBEE,stroke:#C62828 style M3 fill:#C62828,color:#fff ``` @@ -240,7 +240,7 @@ services: db: backend: "sqlite" url: "sqlite+aiosqlite:///./app.db" - + cache: backend: "memory" ttl: 300 @@ -286,7 +286,7 @@ entry_point: src/main.py from xcore.sdk import TrustedBase, AutoDispatchMixin, action, ok class Plugin(AutoDispatchMixin, TrustedBase): - + @action("greet") async def greet(self, payload: dict): name = payload.get("name", "Monde") diff --git a/docs-tech/reference/sdk.md b/docs-tech/reference/sdk.md index 32479d50..7d478da3 100644 --- a/docs-tech/reference/sdk.md +++ b/docs-tech/reference/sdk.md @@ -13,30 +13,30 @@ flowchart TB BP[BasePlugin
Interface low-level] SB[SandboxedBase
Base plugins sandboxed] end - + subgraph Mixins["🔧 Mixins"] ADM[AutoDispatchMixin
Routage @action] RP[RoutedPlugin
Routes HTTP] VM[ValidateMixin
Validation Pydantic] end - + subgraph Decorators["📝 Décorateurs"] D1[@action] D2[@route] D3[@validate_payload] D4[@on / @filter] end - + subgraph Helpers["🛠️ Helpers"] H1[ok / error] H2[Event] H3[Response types] end - + Core --> Mixins Mixins --> Decorators Decorators --> Helpers - + style Core fill:#E3F2FD,stroke:#1976D2 style Mixins fill:#FFF3E0,stroke:#F57C00 style Decorators fill:#E8F5E9,stroke:#388E3C @@ -65,21 +65,21 @@ classDiagram +get_service(name) Any +call_plugin(name, action, payload) Future } - + class AutoDispatchMixin { +async handle(action, payload) dict #_find_action_handler(action) Method } - + class RoutedPlugin { #_routes: List[Route] #discover_routes() None #get_routes() List[Route] } - + TrustedBase <|-- AutoDispatchMixin TrustedBase <|-- RoutedPlugin - + note for TrustedBase "Base class pour plugins trusted" note for AutoDispatchMixin "Routage automatique via @action" note for RoutedPlugin "Support des routes HTTP" @@ -98,9 +98,9 @@ sequenceDiagram S->>L: on_load() Note over L: Initialiser services
Subscrire événements L-->>S: ✅ Ready - + Note over P: Plugin opérationnel... - + S->>U: on_unload() Note over U: Cleanup ressources
Unsubscribe événements U-->>S: ✅ Unloaded @@ -122,32 +122,32 @@ class Plugin(AutoDispatchMixin, TrustedBase): """ Plugin de démonstration montrant toutes les fonctionnalités de TrustedBase. """ - + async def on_load(self) -> None: """ 🎯 Point d'entrée au chargement du plugin. - + Initialiser ici : - Les services (db, cache, scheduler) - Les subscriptions d'événements - L'état interne """ self.logger.info(f"🚀 Chargement de {self.name} v{self.version}") - + # Initialiser les services self.db = self.get_service("db") self.cache = self.get_service("cache") self.scheduler = self.get_service("scheduler") - + # S'abonner à un événement self.ctx.events.on("user.created", self._on_user_created, priority=50) - + self.logger.info("✅ Plugin prêt") - + async def on_unload(self) -> None: """ 🧹 Point de cleanup au déchargement. - + Nettoyer ici : - Connexions ouvertes - Subscriptions événements @@ -155,24 +155,24 @@ class Plugin(AutoDispatchMixin, TrustedBase): """ self.logger.info(f"👋 Déchargement de {self.name}") # Cleanup si nécessaire - + async def on_reload(self) -> None: """ 🔄 Appelé lors d'un hot-reload. - + Re-initialiser l'état sans perdre les connexions. """ self.logger.info(f"🔄 Rechargement de {self.name}") await self.on_load() # Ré-initialiser - + @action("greet") async def greet(self, payload: dict) -> dict: """ 🎯 Action: Saluer un utilisateur. - + Args: payload: {"name": "Votre nom", "language": "fr"} - + Returns: {"status": "ok", "message": "Bonjour, X !"} """ @@ -181,7 +181,7 @@ class Plugin(AutoDispatchMixin, TrustedBase): input_data = GreetInput(**payload) except ValidationError as e: return error(msg=str(e), code="invalid_input", status_code=400) - + # Logique métier greetings = { "fr": "Bonjour", @@ -190,10 +190,10 @@ class Plugin(AutoDispatchMixin, TrustedBase): } greeting = greetings.get(input_data.language, "Hello") message = f"{greeting}, {input_data.name} !" - + self.logger.info(f"Salutation: {message}") return ok(message=message) - + @on("user.created") async def _on_user_created(self, event: Event) -> None: """Handler pour l'événement user.created.""" @@ -214,15 +214,15 @@ class CustomPluginRunner(BasePlugin): """ Implémentation personnalisée d'un runner de plugin. """ - + async def initialize(self, config: dict) -> None: """Initialisation personnalisée.""" pass - + async def execute(self, action: str, payload: dict) -> dict: """Exécution personnalisée.""" return {"status": "ok", "result": "custom"} - + async def cleanup(self) -> None: """Cleanup personnalisé.""" pass @@ -243,7 +243,7 @@ flowchart LR C -->|Trouvé| D[Exécuter handler] C -->|Non trouvé| E[❌ Error 404] D --> F[Retourner résultat] - + style B fill:#E3F2FD,stroke:#1976D2 style D fill:#E8F5E9,stroke:#388E3C style E fill:#FFEBEE,stroke:#C62828 @@ -253,37 +253,37 @@ flowchart LR from xcore.sdk import TrustedBase, AutoDispatchMixin, action, ok class Plugin(AutoDispatchMixin, TrustedBase): - + @action("ping") async def ping(self, payload: dict) -> dict: """Health check simple.""" return ok(status="pong", timestamp=self._now()) - + @action("add") async def add(self, payload: dict) -> dict: """Additionne deux nombres.""" a = payload.get("a", 0) b = payload.get("b", 0) return ok(result=a + b) - + @action("complex_operation") async def complex_op(self, payload: dict) -> dict: """Opération avec validation et services.""" # Accéder au service db = self.get_service("db") - + # Vérifier le cache cache_key = f"op:{payload.get('id')}" cached = await self.cache.get(cache_key) if cached: return ok(result=cached, source="cache") - + # Exécuter l'opération result = await self._do_operation(payload) - + # Caster le résultat await self.cache.set(cache_key, result, ttl=300) - + return ok(result=result, source="computed") ``` @@ -300,16 +300,16 @@ flowchart TB R2[@route("/users/{id}", GET)] R3[@route("/users", POST)] end - + subgraph Mount["📡 FastAPI Mount"] M1[/plugin/my_plugin/users] M2[/plugin/my_plugin/users/{id}] end - + R1 --> M1 R2 --> M2 R3 --> M1 - + style Plugin fill:#FFF3E0,stroke:#F57C00 style Mount fill:#E3F2FD,stroke:#1976D2 ``` @@ -345,12 +345,12 @@ class UserResponse(BaseModel): class Plugin(RoutedPlugin, TrustedBase): """Plugin avec routes HTTP complètes.""" - + @route("/users", method="GET", tags=["users"], summary="Lister les utilisateurs") async def list_users(self, limit: int = 100, offset: int = 0) -> dict: """ Liste tous les utilisateurs avec pagination. - + - **limit**: Nombre maximum d'utilisateurs - **offset**: Décalage pour la pagination """ @@ -359,9 +359,9 @@ class Plugin(RoutedPlugin, TrustedBase): query = f"SELECT * FROM users LIMIT {limit} OFFSET {offset}" result = await session.execute(query) users = result.fetchall() - + return ok(users=[dict(u) for u in users], total=len(users)) - + @route("/users/{user_id}", method="GET", tags=["users"]) async def get_user(self, user_id: int) -> dict: """Récupérer un utilisateur par ID.""" @@ -370,31 +370,31 @@ class Plugin(RoutedPlugin, TrustedBase): query = "SELECT * FROM users WHERE id = :id" result = await session.execute(query, {"id": user_id}) user = result.fetchone() - + if not user: return error(msg="Utilisateur non trouvé", code="not_found", status_code=404) - + return ok(user=dict(user)) - + @route("/users", method="POST", tags=["users"], status_code=201) async def create_user(self, data: UserCreate) -> dict: """Créer un nouvel utilisateur.""" db = self.get_service("db") - + async with db.session() as session: query = "INSERT INTO users (email, name) VALUES (:email, :name) RETURNING *" result = await session.execute(query, dict(data)) user = result.fetchone() await session.commit() - + # Émettre un événement await self.ctx.events.emit("user.created", { "id": user.id, "email": user.email }) - + return ok(user=dict(user)) - + @route("/health", method="GET", tags=["system"]) async def health_check(self) -> dict: """Endpoint de santé du plugin.""" @@ -417,11 +417,11 @@ flowchart TB B --> C{Validation} C -->|✅ Valide| D[Handler exécuté] C -->|❌ Invalide| E[Erreur 400] - + D --> F{type_response ?} F -->|dict| G[Dict validé] F -->|pydantic| H[Instance Pydantic] - + style B fill:#E3F2FD,stroke:#1976D2 style E fill:#FFEBEE,stroke:#C62828 ``` @@ -439,7 +439,7 @@ class CreateUserInput(BaseModel): age: int = Field(ge=0, le=150) class Plugin(AutoDispatchMixin, TrustedBase): - + # Mode 1: Retourne un dict validé @action("create_user_dict") @validate_payload(CreateUserInput, type_response="dict") @@ -448,7 +448,7 @@ class Plugin(AutoDispatchMixin, TrustedBase): email = payload["email"] name = payload["name"] return ok(email=email, name=name) - + # Mode 2: Retourne l'instance Pydantic @action("create_user_model") @validate_payload(CreateUserInput, type_response="pydantic") @@ -470,13 +470,13 @@ Markers pour s'assurer qu'une méthode s'exécute uniquement dans le mode spéci from xcore.sdk import TrustedBase, trusted, sandboxed, action class Plugin(TrustedBase): - + @action("trusted_only") @trusted async def trusted_operation(self, payload: dict) -> dict: """Ne s'exécute que en mode trusted.""" return ok(result="trusted") - + @action("sandboxed_only") @sandboxed async def sandboxed_operation(self, payload: dict) -> dict: @@ -494,12 +494,12 @@ Subscribe un handler à un événement. from xcore.sdk import TrustedBase, on, Event class Plugin(TrustedBase): - + @on("user.created", priority=100) async def handle_user_created(self, event: Event): """Handler avec haute priorité.""" pass - + @on("order.completed", priority=50) async def handle_order_completed(self, event: Event): """Handler avec priorité normale.""" @@ -516,12 +516,12 @@ Enregistre un hook filter ou action. from xcore.sdk import TrustedBase, filter, action_hook class Plugin(TrustedBase): - + @filter("page_title") def modify_title(self, title: str) -> str: """Filter: modifie le titre de page.""" return f"{title} | Mon App" - + @action_hook("before_render") def before_render_log(self, template: str): """Action hook: log avant rendu.""" @@ -545,7 +545,7 @@ flowchart TB D -->|Non| F[Erreur: Action inconnue] E --> G[Retourner résultat] F --> H[Retourner error()] - + style B fill:#E3F2FD,stroke:#1976D2 style E fill:#E8F5E9,stroke:#388E3C style F fill:#FFEBEE,stroke:#C62828 @@ -564,11 +564,11 @@ class Plugin(TrustedBase): # Avec AutoDispatchMixin (concis) class Plugin(AutoDispatchMixin, TrustedBase): - + @action("ping") async def ping(self, payload: dict) -> dict: return ok(status="pong") - + @action("greet") async def greet(self, payload: dict) -> dict: return ok(message=f"Hello !") @@ -585,11 +585,11 @@ from xcore.sdk import TrustedBase, RoutedPlugin, route class Plugin(RoutedPlugin, TrustedBase): """Les routes sont automatiquement montées sur /plugin/{name}/...""" - + @route("/hello", methods=["GET"]) async def hello(self): return {"message": "Hello"} - + @route("/hello/{name}", methods=["GET"]) async def hello_name(self, name: str): return {"message": f"Hello, {name}!"} @@ -662,20 +662,20 @@ class User(BaseModel): class UserRepository(BaseAsyncRepository): """Repository pour les opérations sur les utilisateurs.""" - + async def get_by_email(self, email: str) -> Optional[User]: """Récupérer un utilisateur par email.""" query = "SELECT * FROM users WHERE email = :email" result = await self.session.execute(query, {"email": email}) row = result.fetchone() return User(**dict(row)) if row else None - + async def list_all(self, limit: int = 100) -> List[User]: """Lister tous les utilisateurs.""" query = "SELECT * FROM users LIMIT :limit" result = await self.session.execute(query, {"limit": limit}) return [User(**dict(row)) for row in result.fetchall()] - + async def create(self, data: dict) -> User: """Créer un nouvel utilisateur.""" query = "INSERT INTO users (email, name) VALUES (:email, :name) RETURNING *" diff --git a/docs-tech/requirements.txt b/docs-tech/requirements.txt index 6d519fc9..a28673ed 100644 --- a/docs-tech/requirements.txt +++ b/docs-tech/requirements.txt @@ -1,4 +1,4 @@ mkdocs ==1.6.1 mkdocs-material ==9.7.3 mkdocs-minify-plugin ==0.8.0 -mkdocstrings ==1.0.3 \ No newline at end of file +mkdocstrings ==1.0.3 diff --git a/makefile b/makefile index 97e1d059..f5a5345f 100644 --- a/makefile +++ b/makefile @@ -35,8 +35,9 @@ REPORT_DIR ?= $(CURDIR)/reports STRICT ?= 0 AUTOMATE_CMD ?= all -auto-env: ## Afficher l'environnement Python détecté pour l'automatisation +autobuild-ast: ## Afficher l'environnement Python détecté pour l'automatisation @echo "[INFO] Mode automation: poetry run" + cd xcore/kernel/security/ && poetry run python setup.py build_ext --inplace auto-setup: ## Installer les dépendances (poetry --with dev,docs) @echo "[INFO] Installation dépendances (dev + docs)" @@ -943,6 +944,30 @@ lint-fix: ## Correction automatique des erreurs de linting (SAFE - préserve imp @poetry run autoflake --in-place --recursive --remove-unused-variables --ignore-init-module-imports --exclude=alembic,static,__pycache__ . @echo "✅ Correction automatique terminée (imports préservés)!" +lint-check: ## Vérifier le linting sans modifier les fichiers (pour le CI) + @echo "🔍 Vérification du code (mode CHECK)..." + @echo "📋 1. Vérification black..." + @poetry run black . --check --exclude="(alembic|static|__pycache__)" + @echo "📋 2. Vérification isort..." + @poetry run isort . --check-only --skip=alembic --skip=static --skip=__pycache__ + @echo "📋 3. Vérification flake8..." + @poetry run flake8 . + @echo "✅ Vérification terminée!" + +pre-commit-install: ## Installer les hooks de pre-commit + @echo "📥 Installation des hooks pre-commit..." + @poetry run pre-commit install + +pre-commit-run: ## Lancer manuellement les hooks sur tous les fichiers + @echo "🚀 Lancement de pre-commit sur tous les fichiers..." + @poetry run pre-commit run --all-files + +pre-commit: pre-commit-run ## Alias pour pre-commit-run + +validate-plugins: ## Valider tous les plugins (structure + AST) + @echo "🔌 Validation des plugins..." + @poetry run xcore plugin health + auto-fix: ## Alias pour lint-fix (correction automatique sécurisée) @$(MAKE) lint-fix @@ -966,6 +991,10 @@ test: ## Exécution des tests echo "💡 Créez des tests pour améliorer la qualité"; \ fi +benchmark: ## Exécuter les tests de benchmark avec pytest-benchmark + @echo "⏱️ Exécution des benchmarks..." + @poetry run pytest tests/benchmarks/test_kernel_benchmarks.py --benchmark-only --benchmark-group-by=group --benchmark-warmup=on + security-check: ## Vérification de sécurité basique @echo "🔒 Vérification de sécurité..." @echo "✅ Vérification .env (ne pas commiter)" diff --git a/poetry.lock b/poetry.lock index 6622e7f6..4fbab1ec 100644 --- a/poetry.lock +++ b/poetry.lock @@ -199,6 +199,57 @@ files = [ [package.extras] extras = ["regex"] +[[package]] +name = "black" +version = "26.3.1" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "black-26.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:86a8b5035fce64f5dcd1b794cf8ec4d31fe458cf6ce3986a30deb434df82a1d2"}, + {file = "black-26.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5602bdb96d52d2d0672f24f6ffe5218795736dd34807fd0fd55ccd6bf206168b"}, + {file = "black-26.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c54a4a82e291a1fee5137371ab488866b7c86a3305af4026bdd4dc78642e1ac"}, + {file = "black-26.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:6e131579c243c98f35bce64a7e08e87fb2d610544754675d4a0e73a070a5aa3a"}, + {file = "black-26.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:5ed0ca58586c8d9a487352a96b15272b7fa55d139fc8496b519e78023a8dab0a"}, + {file = "black-26.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:28ef38aee69e4b12fda8dba75e21f9b4f979b490c8ac0baa7cb505369ac9e1ff"}, + {file = "black-26.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf9bf162ed91a26f1adba8efda0b573bc6924ec1408a52cc6f82cb73ec2b142c"}, + {file = "black-26.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:474c27574d6d7037c1bc875a81d9be0a9a4f9ee95e62800dab3cfaadbf75acd5"}, + {file = "black-26.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:5e9d0d86df21f2e1677cc4bd090cd0e446278bcbbe49bf3659c308c3e402843e"}, + {file = "black-26.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9a5e9f45e5d5e1c5b5c29b3bd4265dcc90e8b92cf4534520896ed77f791f4da5"}, + {file = "black-26.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1"}, + {file = "black-26.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f"}, + {file = "black-26.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7"}, + {file = "black-26.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983"}, + {file = "black-26.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb"}, + {file = "black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54"}, + {file = "black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f"}, + {file = "black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56"}, + {file = "black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839"}, + {file = "black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2"}, + {file = "black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78"}, + {file = "black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568"}, + {file = "black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f"}, + {file = "black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c"}, + {file = "black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1"}, + {file = "black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b"}, + {file = "black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=1.0.0" +platformdirs = ">=2" +pytokens = ">=0.4.0,<0.5.0" + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.10)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2) ; sys_platform != \"win32\"", "winloop (>=0.5.0) ; sys_platform == \"win32\""] + [[package]] name = "certifi" version = "2026.2.25" @@ -309,6 +360,18 @@ files = [ [package.dependencies] pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} +[[package]] +name = "cfgv" +version = "3.5.0" +description = "Validate configuration and produce human readable error messages." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0"}, + {file = "cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132"}, +] + [[package]] name = "charset-normalizer" version = "3.4.7" @@ -474,7 +537,126 @@ files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {main = "platform_system == \"Windows\" or sys_platform == \"win32\"", dev = "platform_system == \"Windows\""} +markers = {main = "platform_system == \"Windows\" or sys_platform == \"win32\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} + +[[package]] +name = "coverage" +version = "7.13.5" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.10" +groups = ["main", "dev"] +files = [ + {file = "coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5"}, + {file = "coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf"}, + {file = "coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8"}, + {file = "coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4"}, + {file = "coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d"}, + {file = "coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930"}, + {file = "coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d"}, + {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40"}, + {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878"}, + {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400"}, + {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0"}, + {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0"}, + {file = "coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58"}, + {file = "coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e"}, + {file = "coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d"}, + {file = "coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587"}, + {file = "coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642"}, + {file = "coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b"}, + {file = "coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686"}, + {file = "coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743"}, + {file = "coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75"}, + {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209"}, + {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a"}, + {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e"}, + {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd"}, + {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8"}, + {file = "coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf"}, + {file = "coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9"}, + {file = "coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028"}, + {file = "coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01"}, + {file = "coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422"}, + {file = "coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f"}, + {file = "coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5"}, + {file = "coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376"}, + {file = "coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256"}, + {file = "coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c"}, + {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5"}, + {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09"}, + {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9"}, + {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf"}, + {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c"}, + {file = "coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf"}, + {file = "coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810"}, + {file = "coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de"}, + {file = "coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1"}, + {file = "coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3"}, + {file = "coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26"}, + {file = "coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3"}, + {file = "coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b"}, + {file = "coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a"}, + {file = "coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969"}, + {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161"}, + {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15"}, + {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1"}, + {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6"}, + {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17"}, + {file = "coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85"}, + {file = "coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b"}, + {file = "coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664"}, + {file = "coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d"}, + {file = "coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0"}, + {file = "coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806"}, + {file = "coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3"}, + {file = "coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9"}, + {file = "coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd"}, + {file = "coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606"}, + {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e"}, + {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0"}, + {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87"}, + {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479"}, + {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2"}, + {file = "coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a"}, + {file = "coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819"}, + {file = "coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911"}, + {file = "coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f"}, + {file = "coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e"}, + {file = "coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a"}, + {file = "coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510"}, + {file = "coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247"}, + {file = "coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6"}, + {file = "coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0"}, + {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882"}, + {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740"}, + {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16"}, + {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0"}, + {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0"}, + {file = "coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc"}, + {file = "coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633"}, + {file = "coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8"}, + {file = "coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b"}, + {file = "coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c"}, + {file = "coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9"}, + {file = "coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29"}, + {file = "coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607"}, + {file = "coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90"}, + {file = "coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3"}, + {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab"}, + {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562"}, + {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2"}, + {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea"}, + {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a"}, + {file = "coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215"}, + {file = "coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43"}, + {file = "coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45"}, + {file = "coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61"}, + {file = "coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179"}, +] + +[package.extras] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cryptography" @@ -559,6 +741,18 @@ files = [ {file = "csscompressor-0.9.5.tar.gz", hash = "sha256:afa22badbcf3120a4f392e4d22f9fff485c044a1feda4a950ecc5eba9dd31a05"}, ] +[[package]] +name = "distlib" +version = "0.4.0" +description = "Distribution utilities" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"}, + {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, +] + [[package]] name = "dnspython" version = "2.8.0" @@ -848,6 +1042,35 @@ files = [ {file = "fastar-0.11.0.tar.gz", hash = "sha256:aa7f100f7313c03fdb20f1385927ba95671071ba308ad0c1763fef295e1895ce"}, ] +[[package]] +name = "filelock" +version = "3.29.0" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258"}, + {file = "filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90"}, +] + +[[package]] +name = "flake8" +version = "7.3.0" +description = "the modular source code checker: pep8 pyflakes and co" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e"}, + {file = "flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872"}, +] + +[package.dependencies] +mccabe = ">=0.7.0,<0.8.0" +pycodestyle = ">=2.14.0,<2.15.0" +pyflakes = ">=3.4.0,<3.5.0" + [[package]] name = "ghp-import" version = "2.1.0" @@ -1193,6 +1416,21 @@ http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] zstd = ["zstandard (>=0.18.0)"] +[[package]] +name = "identify" +version = "2.6.19" +description = "File identification library for Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a"}, + {file = "identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842"}, +] + +[package.extras] +license = ["ukkonen"] + [[package]] name = "idna" version = "3.11" @@ -1208,6 +1446,33 @@ files = [ [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["main", "dev"] +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + +[[package]] +name = "isort" +version = "8.0.1" +description = "A Python utility / library to sort Python imports." +optional = false +python-versions = ">=3.10.0" +groups = ["dev"] +files = [ + {file = "isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75"}, + {file = "isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d"}, +] + +[package.extras] +colors = ["colorama"] + [[package]] name = "jinja2" version = "3.1.6" @@ -1396,6 +1661,18 @@ files = [ {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, ] +[[package]] +name = "mccabe" +version = "0.7.0" +description = "McCabe checker, plugin for flake8" +optional = false +python-versions = ">=3.6" +groups = ["dev"] +files = [ + {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, + {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -1587,6 +1864,18 @@ griffelib = ">=2.0" mkdocs-autorefs = ">=1.4" mkdocstrings = ">=0.30" +[[package]] +name = "mypy-extensions" +version = "1.1.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, +] + [[package]] name = "mysql-connector-python" version = "9.6.0" @@ -1630,6 +1919,18 @@ gssapi = ["gssapi (==1.8.3)"] telemetry = ["opentelemetry-api (==1.33.1)", "opentelemetry-exporter-otlp-proto-http (==1.33.1)", "opentelemetry-sdk (==1.33.1)"] webauthn = ["fido2 (==1.1.2)"] +[[package]] +name = "nodeenv" +version = "1.10.0" +description = "Node.js virtual environment builder" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main"] +files = [ + {file = "nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827"}, + {file = "nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb"}, +] + [[package]] name = "opshero" version = "0.1.6" @@ -1662,7 +1963,7 @@ version = "26.1" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["docs"] +groups = ["main", "dev", "docs"] files = [ {file = "packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f"}, {file = "packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de"}, @@ -1690,7 +1991,7 @@ version = "1.0.4" description = "Utility library for gitignore style pattern matching of file paths." optional = false python-versions = ">=3.9" -groups = ["docs"] +groups = ["dev", "docs"] files = [ {file = "pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723"}, {file = "pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645"}, @@ -1829,12 +2130,47 @@ version = "4.9.6" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.10" -groups = ["dev", "docs"] +groups = ["main", "dev", "docs"] files = [ {file = "platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917"}, {file = "platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a"}, ] +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "pre-commit" +version = "4.6.0" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b"}, + {file = "pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + [[package]] name = "psutil" version = "7.2.2" @@ -1887,6 +2223,18 @@ files = [ {file = "psycopg2-2.9.12.tar.gz", hash = "sha256:1dedb1c7a1d8552c4a6044c6b1c41a52e6a8e2d144af83eccac758076b1b7c15"}, ] +[[package]] +name = "py-cpuinfo" +version = "9.0.0" +description = "Get CPU info with pure Python" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690"}, + {file = "py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5"}, +] + [[package]] name = "pyasn1" version = "0.6.3" @@ -2145,6 +2493,18 @@ gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"] toml = ["tomli (>=2.0.1)"] yaml = ["pyyaml (>=6.0.1)"] +[[package]] +name = "pyflakes" +version = "3.4.0" +description = "passive checker of Python programs" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f"}, + {file = "pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58"}, +] + [[package]] name = "pygments" version = "2.20.0" @@ -2179,6 +2539,99 @@ pyyaml = "*" [package.extras] extra = ["pygments (>=2.19.1)"] +[[package]] +name = "pytest" +version = "9.0.3" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.10" +groups = ["main", "dev"] +files = [ + {file = "pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9"}, + {file = "pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +iniconfig = ">=1.0.1" +packaging = ">=22" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-benchmark" +version = "5.2.3" +description = "A ``pytest`` fixture for benchmarking code. It will group the tests into rounds that are calibrated to the chosen timer." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest_benchmark-5.2.3-py3-none-any.whl", hash = "sha256:bc839726ad20e99aaa0d11a127445457b4219bdb9e80a1afc4b51da7f96b0803"}, + {file = "pytest_benchmark-5.2.3.tar.gz", hash = "sha256:deb7317998a23c650fd4ff76e1230066a76cb45dcece0aca5607143c619e7779"}, +] + +[package.dependencies] +py-cpuinfo = "*" +pytest = ">=8.1" + +[package.extras] +aspect = ["aspectlib"] +elasticsearch = ["elasticsearch"] +histogram = ["pygal", "pygaljs", "setuptools"] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678"}, + {file = "pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2"}, +] + +[package.dependencies] +coverage = {version = ">=7.10.6", extras = ["toml"]} +pluggy = ">=1.2" +pytest = ">=7" + +[package.extras] +testing = ["process-tests", "pytest-xdist", "virtualenv"] + +[[package]] +name = "pytest-cover" +version = "3.0.0" +description = "Pytest plugin for measuring coverage. Forked from `pytest-cov`." +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "pytest-cover-3.0.0.tar.gz", hash = "sha256:5bdb6c1cc3dd75583bb7bc2c57f5e1034a1bfcb79d27c71aceb0b16af981dbf4"}, + {file = "pytest_cover-3.0.0-py2.py3-none-any.whl", hash = "sha256:578249955eb3b5f3991209df6e532bb770b647743b7392d3d97698dc02f39ebb"}, +] + +[package.dependencies] +pytest-cov = ">=2.0" + +[[package]] +name = "pytest-coverage" +version = "0.0" +description = "Pytest plugin for measuring coverage. Forked from `pytest-cov`." +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "pytest-coverage-0.0.tar.gz", hash = "sha256:db6af2cbd7e458c7c9fd2b4207cee75258243c8a81cad31a7ee8cfad5be93c05"}, + {file = "pytest_coverage-0.0-py2.py3-none-any.whl", hash = "sha256:dedd084c5e74d8e669355325916dc011539b190355021b037242514dee546368"}, +] + +[package.dependencies] +pytest-cover = "*" + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -2194,6 +2647,26 @@ files = [ [package.dependencies] six = ">=1.5" +[[package]] +name = "python-discovery" +version = "1.2.2" +description = "Python interpreter discovery" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a"}, + {file = "python_discovery-1.2.2.tar.gz", hash = "sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb"}, +] + +[package.dependencies] +filelock = ">=3.15.4" +platformdirs = ">=4.3.6,<5" + +[package.extras] +docs = ["furo (>=2025.12.19)", "sphinx (>=9.1)", "sphinx-autodoc-typehints (>=3.6.3)", "sphinxcontrib-mermaid (>=2)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.5.4)", "pytest (>=8.3.5)", "pytest-mock (>=3.14)", "setuptools (>=75.1)"] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -2245,6 +2718,61 @@ files = [ {file = "python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17"}, ] +[[package]] +name = "pytokens" +version = "0.4.1" +description = "A Fast, spec compliant Python 3.14+ tokenizer that runs on older Pythons." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5"}, + {file = "pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe"}, + {file = "pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c"}, + {file = "pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7"}, + {file = "pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2"}, + {file = "pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440"}, + {file = "pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc"}, + {file = "pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d"}, + {file = "pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16"}, + {file = "pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6"}, + {file = "pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083"}, + {file = "pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1"}, + {file = "pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1"}, + {file = "pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9"}, + {file = "pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68"}, + {file = "pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b"}, + {file = "pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f"}, + {file = "pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1"}, + {file = "pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4"}, + {file = "pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78"}, + {file = "pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321"}, + {file = "pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa"}, + {file = "pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d"}, + {file = "pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324"}, + {file = "pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9"}, + {file = "pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb"}, + {file = "pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3"}, + {file = "pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975"}, + {file = "pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a"}, + {file = "pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918"}, + {file = "pytokens-0.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:da5baeaf7116dced9c6bb76dc31ba04a2dc3695f3d9f74741d7910122b456edc"}, + {file = "pytokens-0.4.1-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11edda0942da80ff58c4408407616a310adecae1ddd22eef8c692fe266fa5009"}, + {file = "pytokens-0.4.1-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0fc71786e629cef478cbf29d7ea1923299181d0699dbe7c3c0f4a583811d9fc1"}, + {file = "pytokens-0.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dcafc12c30dbaf1e2af0490978352e0c4041a7cde31f4f81435c2a5e8b9cabb6"}, + {file = "pytokens-0.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:42f144f3aafa5d92bad964d471a581651e28b24434d184871bd02e3a0d956037"}, + {file = "pytokens-0.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:34bcc734bd2f2d5fe3b34e7b3c0116bfb2397f2d9666139988e7a3eb5f7400e3"}, + {file = "pytokens-0.4.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941d4343bf27b605e9213b26bfa1c4bf197c9c599a9627eb7305b0defcfe40c1"}, + {file = "pytokens-0.4.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3ad72b851e781478366288743198101e5eb34a414f1d5627cdd585ca3b25f1db"}, + {file = "pytokens-0.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:682fa37ff4d8e95f7df6fe6fe6a431e8ed8e788023c6bcc0f0880a12eab80ad1"}, + {file = "pytokens-0.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:30f51edd9bb7f85c748979384165601d028b84f7bd13fe14d3e065304093916a"}, + {file = "pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de"}, + {file = "pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a"}, +] + +[package.extras] +dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "twine", "wheel"] + [[package]] name = "pyyaml" version = "6.0.3" @@ -2834,6 +3362,21 @@ typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\"" [package.extras] full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] +[[package]] +name = "tabulate" +version = "0.10.0" +description = "Pretty-print tabular data" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3"}, + {file = "tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d"}, +] + +[package.extras] +widechars = ["wcwidth"] + [[package]] name = "toml" version = "0.10.2" @@ -3077,6 +3620,24 @@ dev = ["Cython (>=3.0,<4.0)", "setuptools (>=60)"] docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx_rtd_theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] test = ["aiohttp (>=3.10.5)", "flake8 (>=6.1,<7.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=25.3.0,<25.4.0)", "pycodestyle (>=2.11.0,<2.12.0)"] +[[package]] +name = "virtualenv" +version = "21.3.0" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "virtualenv-21.3.0-py3-none-any.whl", hash = "sha256:4d28ee41f6d9ec8f1f00cd472b9ffbcedda1b3d3b9a575b5c94a2d004fd51bd7"}, + {file = "virtualenv-21.3.0.tar.gz", hash = "sha256:733750db978ec95c2d8eb4feadaa57091002bce404cb39ba69899cf7bd28944e"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = {version = ">=3.24.2,<4", markers = "python_version >= \"3.10\""} +platformdirs = ">=3.9.1,<5" +python-discovery = ">=1.2.2" + [[package]] name = "watchdog" version = "6.0.0" @@ -3316,4 +3877,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">=3.11,<4.0" -content-hash = "0bc0246edecefbb7dba47b12cd3e2ad7dc9b43916048e25bf6b6d420f783326b" +content-hash = "a51550b5b5268ed2290a691e71e17447e43b0c00b9236f6301a3d7564ed6d926" diff --git a/pyproject.toml b/pyproject.toml index 1b1406ce..456daede 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,10 +51,11 @@ redis = {extras = ["hiredis"], version = "^7.0.0"} pyyaml = "^6.0.3" pybind11 = "^3.0.3" setuptools = "^82.0.1" +pytest-cov = "^7.1.0" [tool.poetry.group.dev.dependencies] -pre-commit = "^4.3.0" +pre-commit = "^4.6.0" isort = "^8.0.1" black = "^26.3.1" flake8 = "^7.3.0" @@ -62,6 +63,7 @@ autoflake = "^2.3.1" autopep8 = "^2.3.2" pytest = "^8.4.2" pytest-asyncio = "^1.2.0" + httpx = "^0.28.1" faker = "^37.8.0" pytest-cov = "^7.0.0" @@ -83,13 +85,18 @@ build-backend = "poetry.core.masonry.api" dev = [ "autopep8>=2.3.2", "opshero (>=0.1.2,<0.2.0)", + "isort (>=8.0.1,<9.0.0)", + "flake8 (>=7.3.0,<8.0.0)", + "black (>=26.3.1,<27.0.0)", + "pytest-benchmark (>=5.2.3,<6.0.0)", + "tabulate (>=0.10.0,<0.11.0)", ] docs = [ "mkdocs (==1.6.1)", "mkdocs-material (==9.7.3)", "mkdocs-minify-plugin (==0.8.0)", "mkdocstrings (==1.0.3)", - "mkdocstrings-python (>=2.0.3,<3.0.0)" + "mkdocstrings-python (>=2.0.3,<3.0.0)", ] [tool.flake8] max-line-length = 88 diff --git a/scripts/benchmarks.py b/scripts/benchmarks.py new file mode 100644 index 00000000..4312c464 --- /dev/null +++ b/scripts/benchmarks.py @@ -0,0 +1,1403 @@ +""" +xcore Performance Benchmark Suite +================================== +Tests : + 1. Plugin load / unload time (single, batch, concurrent) + 2. Plugin call throughput (sequential, concurrent) + 3. Middleware pipeline overhead + 4. EventBus / HookManager throughput + 5. PermissionEngine throughput + cache effectiveness + 6. CacheService throughput (memory backend) + 7. Maximum plugin capacity simulation + 8. Memory footprint per plugin + 9. Overall system stress test + +Usage : + python xcore_bench.py # full suite + python xcore_bench.py --suite plugins # only plugin benchmarks + python xcore_bench.py --capacity 200 # test up to 200 plugins + python xcore_bench.py --output report.json +""" + +from __future__ import annotations + +import argparse +import asyncio +import gc +import json +import os +import shutil +import statistics +import sys +import tempfile +import textwrap +import time +import traceback +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any + +# ── FIX: sys.path doit être inséré AVANT tous les imports xcore ────────────── +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from xcore.sdk.plugin_base import PluginManifest # noqa: E402 + +# ── deps optionnels ────────────────────────────────────────────────────────── +try: + import psutil + + _HAS_PSUTIL = True +except ImportError: + _HAS_PSUTIL = False + print("[WARN] psutil absent — memory metrics désactivés (pip install psutil)") + +try: + from tabulate import tabulate + + _HAS_TABULATE = True +except ImportError: + _HAS_TABULATE = False + + +# ╔══════════════════════════════════════════════════════════════════════════╗ +# ║ Structures de données du rapport ║ +# ╚══════════════════════════════════════════════════════════════════════════╝ + + +@dataclass +class BenchResult: + name: str + category: str + iterations: int + total_ms: float + mean_ms: float + median_ms: float + p95_ms: float + p99_ms: float + min_ms: float + max_ms: float + std_ms: float + throughput_ops_sec: float + memory_delta_mb: float = 0.0 + errors: int = 0 + notes: str = "" + + def as_row(self) -> list: + return [ + self.name, + f"{self.mean_ms:.3f}", + f"{self.median_ms:.3f}", + f"{self.p95_ms:.3f}", + f"{self.p99_ms:.3f}", + f"{self.min_ms:.3f}", + f"{self.max_ms:.3f}", + f"{self.throughput_ops_sec:.0f}", + f"{self.memory_delta_mb:.2f}", + "✅" if self.errors == 0 else f"❌ {self.errors}", + ] + + +@dataclass +class BenchReport: + xcore_version: str = "unknown" + python_version: str = sys.version.split()[0] + platform: str = sys.platform + timestamp: str = "" + cpu_count: int = os.cpu_count() or 1 + total_duration_s: float = 0.0 + results: list[BenchResult] = field(default_factory=list) + capacity: dict[str, Any] = field(default_factory=dict) + system_info: dict[str, Any] = field(default_factory=dict) + + def add(self, r: BenchResult) -> None: + self.results.append(r) + + def summary_table(self) -> str: + headers = [ + "Benchmark", + "Mean(ms)", + "Med(ms)", + "P95(ms)", + "P99(ms)", + "Min(ms)", + "Max(ms)", + "Ops/s", + "ΔMem(MB)", + "Status", + ] + rows = [r.as_row() for r in self.results] + if _HAS_TABULATE: + return tabulate(rows, headers=headers, tablefmt="rounded_outline") + sep = "-" * 120 + lines = [sep, " ".join(f"{h:<14}" for h in headers), sep] + for row in rows: + lines.append(" ".join(f"{str(c):<14}" for c in row)) + lines.append(sep) + return "\n".join(lines) + + +# ╔══════════════════════════════════════════════════════════════════════════╗ +# ║ Helpers ║ +# ╚══════════════════════════════════════════════════════════════════════════╝ + + +def _mem_mb() -> float: + if not _HAS_PSUTIL: + return 0.0 + return psutil.Process().memory_info().rss / 1024 / 1024 + + +def _percentile(sorted_samples: list[float], pct: float) -> float: + """Retourne le percentile pct (0–1) d'une liste déjà triée, sans débordement.""" + if not sorted_samples: + return 0.0 + idx = min(int(len(sorted_samples) * pct), len(sorted_samples) - 1) + return sorted_samples[idx] + + +def _measure( + samples: list[float], + iterations: int, + errors: int, + name: str, + category: str, + mem_delta: float = 0.0, + notes: str = "", +) -> BenchResult: + """ + Construit un BenchResult à partir de samples déjà exprimés en millisecondes. + + FIX: suppression de la heuristique `* 1000 if value < 10 else value` qui + causait une double-conversion : tous les sites d'appel collectent déjà + leurs samples en ms (via `(perf_counter() - t0) * 1000`). + """ + total = sum(samples) + mean = statistics.mean(samples) + med = statistics.median(samples) + std = statistics.stdev(samples) if len(samples) > 1 else 0.0 + sorted_s = sorted(samples) + s95 = _percentile(sorted_s, 0.95) + s99 = _percentile(sorted_s, 0.99) + thr = iterations / (total / 1000) if total > 0 else 0.0 + + return BenchResult( + name=name, + category=category, + iterations=iterations, + total_ms=total, + mean_ms=mean, + median_ms=med, + p95_ms=s95, + p99_ms=s99, + min_ms=min(samples), + max_ms=max(samples), + std_ms=std, + throughput_ops_sec=thr, + memory_delta_mb=mem_delta, + errors=errors, + notes=notes, + ) + + +def _make_plugin_dir( + base: Path, name: str, mode: str = "trusted", extra_code: str = "" +) -> Path: + """Crée un plugin minimal valide dans base/name.""" + plugin_dir = base / name + src_dir = plugin_dir / "src" + src_dir.mkdir(parents=True, exist_ok=True) + + (plugin_dir / "plugin.yaml").write_text( + textwrap.dedent( + f""" + name: {name} + version: 1.0.0 + execution_mode: {mode} + description: Benchmark plugin + permissions: + - resource: "ping" + actions: ["read", "write"] + effect: allow + - resource: "echo" + actions: ["read", "write"] + effect: allow + - resource: "compute" + actions: ["read", "write"] + effect: allow + + + resources: + timeout_seconds: 30 + rate_limit: + calls: 100000 + period_seconds: 60 + """ + ).strip() + ) + + (src_dir / "main.py").write_text( + textwrap.dedent( + f""" + from xcore.kernel.api.contract import BasePlugin + + class Plugin(BasePlugin): + call_count = 0 + + async def handle(self, action: str, payload: dict) -> dict: + Plugin.call_count += 1 + if action == "ping": + return {{"status": "ok", "pong": True, "count": Plugin.call_count}} + if action == "echo": + return {{"status": "ok", "data": payload}} + if action == "compute": + n = payload.get("n", 100) + result = sum(i * i for i in range(n)) + return {{"status": "ok", "result": result}} + return {{"status": "error", "msg": f"unknown action {{action}}"}} + + async def on_load(self): + pass + + async def on_unload(self): + pass + {extra_code} + """ + ).strip() + ) + + return plugin_dir + + +def _make_xcore_config(plugins_dir: Path) -> str: + """Crée un fichier de config minimal et retourne son chemin.""" + cfg_file = plugins_dir.parent / "xcore_bench.yaml" + cfg_file.write_text( + textwrap.dedent( + f""" + app: + name: xcore-bench + secret_key: bench-secret-key-minimum-32-chars!! + server_key: bench-server-key-minimum-32-chars!! + env: development + + plugins: + directory: {plugins_dir} + strict_trusted: false + interval: 0 + + services: + databases: {{}} + cache: + backend: memory + ttl: 300 + max_size: 100000 + scheduler: + enabled: false + """ + ).strip() + ) + return str(cfg_file) + + +# ╔══════════════════════════════════════════════════════════════════════════╗ +# ║ Section 1 — Plugin Load / Unload ║ +# ╚══════════════════════════════════════════════════════════════════════════╝ + + +class PluginLifecycleBench: + """Benchmarks de chargement/déchargement de plugins.""" + + CATEGORY = "plugin_lifecycle" + + async def bench_single_load_unload(self, n_runs: int = 20) -> list[BenchResult]: + """Charge et décharge 1 plugin, N fois de suite.""" + results = [] + tmp = Path(tempfile.mkdtemp(prefix="xcore_bench_")) + try: + from xcore.kernel.context import KernelContext + from xcore.kernel.runtime.lifecycle import LifecycleManager + from xcore.kernel.security.validation import ManifestValidator + + plugin_dir = _make_plugin_dir(tmp, "bench_single") + manifest, is_ok, version = ManifestValidator().load_and_validate(plugin_dir) + # FIX: suppression du print(manifest, is_ok, version) de debug + services_mock = _MockServices() + + load_samples: list[float] = [] + unload_samples: list[float] = [] + errors = 0 + + for _ in range(n_runs): + ctx = _make_ctx(services_mock) + lm = LifecycleManager( + manifest if isinstance(manifest, PluginManifest) else None, ctx + ) + try: + t0 = time.perf_counter() + await lm.load() + load_samples.append((time.perf_counter() - t0) * 1000) + + t0 = time.perf_counter() + await lm.unload() + unload_samples.append((time.perf_counter() - t0) * 1000) + except Exception as e: + errors += 1 + print(f" [WARN] load/unload error: {e}") + + if load_samples: + results.append( + _measure( + load_samples, + n_runs, + errors, + "single_plugin_load", + self.CATEGORY, + notes=f"{n_runs} runs", + ) + ) + if unload_samples: + results.append( + _measure( + unload_samples, + n_runs, + errors, + "single_plugin_unload", + self.CATEGORY, + notes=f"{n_runs} runs", + ) + ) + finally: + shutil.rmtree(tmp, ignore_errors=True) + return results + + async def bench_batch_load( + self, batch_sizes: list[int] = (5, 20, 50, 100) + ) -> list[BenchResult]: + """Charge N plugins en une seule passe (load_all).""" + results = [] + for n in batch_sizes: + tmp = Path(tempfile.mkdtemp(prefix="xcore_bench_")) + try: + for i in range(n): + _make_plugin_dir(tmp / "plugins", f"plugin_{i:04d}") + + cfg_path = _make_xcore_config(tmp / "plugins") + mem_before = _mem_mb() + t0 = time.perf_counter() + + from xcore import Xcore + + app = Xcore(config_path=cfg_path) + try: + await app.boot() + elapsed_ms = (time.perf_counter() - t0) * 1000 + mem_after = _mem_mb() + loaded = len(app.plugins.list_plugins()) + + results.append( + BenchResult( + name=f"batch_load_{n}_plugins", + category=self.CATEGORY, + iterations=n, + total_ms=elapsed_ms, + mean_ms=elapsed_ms / n, + median_ms=elapsed_ms / n, + p95_ms=elapsed_ms / n * 1.5, + p99_ms=elapsed_ms / n * 2.0, + min_ms=0, + max_ms=elapsed_ms, + std_ms=0, + throughput_ops_sec=n / (elapsed_ms / 1000), + memory_delta_mb=mem_after - mem_before, + errors=n - loaded, + notes=f"{loaded}/{n} loaded, " + f"{(mem_after - mem_before) / n:.2f}MB/plugin", + ) + ) + finally: + await app.shutdown() + except Exception as e: + print(f" [ERR] batch_load_{n}: {e}") + traceback.print_exc() + finally: + shutil.rmtree(tmp, ignore_errors=True) + gc.collect() + + return results + + async def bench_reload(self, n_runs: int = 15) -> list[BenchResult]: + """Mesure le temps de rechargement d'un plugin.""" + tmp = Path(tempfile.mkdtemp(prefix="xcore_bench_")) + samples: list[float] = [] + errors = 0 + try: + from xcore.kernel.context import KernelContext + from xcore.kernel.runtime.lifecycle import LifecycleManager + from xcore.kernel.security.validation import ManifestValidator + + plugin_dir = _make_plugin_dir(tmp, "bench_reload") + manifest, _, _ = ManifestValidator().load_and_validate(plugin_dir) + ctx = _make_ctx(_MockServices()) + + # FIX: remplacement du bare `except: quit()` par une vraie gestion d'erreur + try: + lm = LifecycleManager(manifest, ctx) + except Exception as e: + raise RuntimeError( + f"LifecycleManager instanciation failed " + f"(manifest type={type(manifest).__name__}): {e}" + ) from e + + await lm.load() + + for _ in range(n_runs): + try: + t0 = time.perf_counter() + await lm.reload() + samples.append((time.perf_counter() - t0) * 1000) + except Exception as e: + errors += 1 + # FIX: log the error instead of silently swallowing it + print(f" [WARN] reload error: {e}") + + await lm.unload() + finally: + shutil.rmtree(tmp, ignore_errors=True) + + return ( + [_measure(samples, n_runs, errors, "plugin_reload", self.CATEGORY)] + if samples + else [] + ) + + +# ╔══════════════════════════════════════════════════════════════════════════╗ +# ║ Section 2 — Plugin Call Throughput ║ +# ╚══════════════════════════════════════════════════════════════════════════╝ + + +class PluginCallBench: + """Throughput des appels plugin via le Supervisor.""" + + CATEGORY = "plugin_calls" + + async def bench_sequential_calls(self, n_calls: int = 500) -> list[BenchResult]: + tmp = Path(tempfile.mkdtemp(prefix="xcore_bench_")) + results = [] + try: + _make_plugin_dir(tmp / "plugins", "bench_calls") + cfg = _make_xcore_config(tmp / "plugins") + app = _make_xcore_instance(cfg) + await app.boot() + + # warm-up + for _ in range(10): + await app.plugins.call("bench_calls", "ping", {}) + + samples: list[float] = [] + errors = 0 + for _ in range(n_calls): + try: + t0 = time.perf_counter() + r = await app.plugins.call("bench_calls", "ping", {}) + samples.append((time.perf_counter() - t0) * 1000) + if r.get("status") != "ok": + errors += 1 + except Exception: + errors += 1 + + results.append( + _measure( + samples, + n_calls, + errors, + "sequential_call_ping", + self.CATEGORY, + notes=f"single plugin, {n_calls} calls", + ) + ) + + # echo avec payload + payload = {"key": "value", "data": list(range(10)), "nested": {"a": 1}} + samples = [] + for _ in range(n_calls): + t0 = time.perf_counter() + await app.plugins.call("bench_calls", "echo", payload) + samples.append((time.perf_counter() - t0) * 1000) + results.append( + _measure( + samples, + n_calls, + 0, + "sequential_call_echo_payload", + self.CATEGORY, + ) + ) + + await app.shutdown() + finally: + shutil.rmtree(tmp, ignore_errors=True) + return results + + async def bench_concurrent_calls( + self, concurrency_levels: list[int] = (5, 20, 50, 100) + ) -> list[BenchResult]: + results = [] + for concurrency in concurrency_levels: + tmp = Path(tempfile.mkdtemp(prefix="xcore_bench_")) + try: + _make_plugin_dir(tmp / "plugins", "bench_concurrent") + cfg = _make_xcore_config(tmp / "plugins") + app = _make_xcore_instance(cfg) + await app.boot() + + # warm-up + await asyncio.gather( + *[ + app.plugins.call("bench_concurrent", "ping", {}) + for _ in range(5) + ] + ) + + errors = 0 + t0 = time.perf_counter() + tasks = [ + app.plugins.call("bench_concurrent", "ping", {}) + for _ in range(concurrency) + ] + raw = await asyncio.gather(*tasks, return_exceptions=True) + total_ms = (time.perf_counter() - t0) * 1000 + + for r in raw: + if isinstance(r, Exception) or ( + isinstance(r, dict) and r.get("status") != "ok" + ): + errors += 1 + + results.append( + BenchResult( + name=f"concurrent_{concurrency}_calls", + category=self.CATEGORY, + iterations=concurrency, + total_ms=total_ms, + mean_ms=total_ms / concurrency, + median_ms=total_ms / concurrency, + p95_ms=total_ms / concurrency * 1.3, + p99_ms=total_ms / concurrency * 1.5, + min_ms=0, + max_ms=total_ms, + std_ms=0, + throughput_ops_sec=concurrency / (total_ms / 1000), + errors=errors, + notes=f"asyncio.gather({concurrency})", + ) + ) + + await app.shutdown() + except Exception as e: + print(f" [ERR] concurrent_{concurrency}: {e}") + finally: + shutil.rmtree(tmp, ignore_errors=True) + gc.collect() + return results + + async def bench_multi_plugin_routing( + self, plugin_counts: list[int] = (5, 10, 25, 50) + ) -> list[BenchResult]: + """Appels distribués sur N plugins (routing overhead).""" + results = [] + for n in plugin_counts: + tmp = Path(tempfile.mkdtemp(prefix="xcore_bench_")) + try: + for i in range(n): + _make_plugin_dir(tmp / "plugins", f"router_{i:03d}") + cfg = _make_xcore_config(tmp / "plugins") + app = _make_xcore_instance(cfg) + await app.boot() + + loaded = app.plugins.list_plugins() + n_calls = 200 + samples: list[float] = [] + for i in range(n_calls): + pname = loaded[i % len(loaded)] + app.plugins._permissions.grant_all(pname) + t0 = time.perf_counter() + await app.plugins.call(pname, "ping", {}) + samples.append((time.perf_counter() - t0) * 1000) + + results.append( + _measure( + samples, + n_calls, + 0, + f"routing_{n}_plugins", + self.CATEGORY, + notes=f"{len(loaded)} loaded, {n_calls} calls round-robin", + ) + ) + await app.shutdown() + except Exception as e: + print(f" [ERR] routing_{n}: {e}") + finally: + shutil.rmtree(tmp, ignore_errors=True) + gc.collect() + return results + + +# ╔══════════════════════════════════════════════════════════════════════════╗ +# ║ Section 3 — Middleware Pipeline ║ +# ╚══════════════════════════════════════════════════════════════════════════╝ + + +class MiddlewareBench: + CATEGORY = "middleware" + + async def bench_pipeline_overhead(self, n: int = 1000) -> list[BenchResult]: + """Compare appel direct vs appel via pipeline (4 middlewares).""" + from xcore.kernel.runtime.middlewares.middleware import ( + Middleware, + MiddlewarePipeline, + ) + + class NoopMiddleware(Middleware): + async def __call__(self, pn, ac, pay, nxt, h, **kw): + return await nxt(pn, ac, pay, h, **kw) + + async def final(pn, ac, pay, **kw): + return {"status": "ok"} + + results = [] + + # 0 middleware — appel direct + pipeline0 = MiddlewarePipeline([], final) + samples: list[float] = [] + for _ in range(n): + t0 = time.perf_counter() + await pipeline0.execute("p", "a", {}) + samples.append((time.perf_counter() - t0) * 1000) + results.append(_measure(samples, n, 0, "pipeline_0_middlewares", self.CATEGORY)) + + # 4 middlewares noop + pipeline4 = MiddlewarePipeline([NoopMiddleware() for _ in range(4)], final) + samples = [] + for _ in range(n): + t0 = time.perf_counter() + await pipeline4.execute("p", "a", {}) + samples.append((time.perf_counter() - t0) * 1000) + results.append(_measure(samples, n, 0, "pipeline_4_middlewares", self.CATEGORY)) + + overhead_ms = statistics.mean( + [r.mean_ms for r in results if "4" in r.name] + ) - statistics.mean([r.mean_ms for r in results if "0" in r.name]) + print(f" → Middleware overhead (4 layers): {overhead_ms:.4f}ms/call") + return results + + +# ╔══════════════════════════════════════════════════════════════════════════╗ +# ║ Section 4 — EventBus / HookManager ║ +# ╚══════════════════════════════════════════════════════════════════════════╝ + + +class EventsBench: + CATEGORY = "events" + + async def bench_eventbus(self, n: int = 2000) -> list[BenchResult]: + from xcore.kernel.events.bus import EventBus + + results = [] + + # emit sans handlers + bus = EventBus() + samples: list[float] = [] + for _ in range(n): + t0 = time.perf_counter() + await bus.emit("test.event", {"data": 1}) + samples.append((time.perf_counter() - t0) * 1000) + results.append( + _measure(samples, n, 0, "eventbus_emit_no_handlers", self.CATEGORY) + ) + + # emit avec 1 handler async + counter = [0] + + @bus.on("test.event") + async def h(event): + counter[0] += 1 + + samples = [] + for _ in range(n): + t0 = time.perf_counter() + await bus.emit("test.event", {"data": 1}) + samples.append((time.perf_counter() - t0) * 1000) + results.append( + _measure(samples, n, 0, "eventbus_emit_1_handler", self.CATEGORY) + ) + + # emit avec 10 handlers + bus2 = EventBus() + for i in range(10): + + @bus2.on("test.event") + async def _h(event, _i=i): + pass + + samples = [] + for _ in range(n): + t0 = time.perf_counter() + await bus2.emit("test.event", {}) + samples.append((time.perf_counter() - t0) * 1000) + results.append( + _measure(samples, n, 0, "eventbus_emit_10_handlers", self.CATEGORY) + ) + + # wildcard matching + bus3 = EventBus() + + @bus3.on("user.*") + async def _wh(event): + pass + + samples = [] + for i in range(n): + t0 = time.perf_counter() + await bus3.emit(f"user.event_{i % 20}", {}) + samples.append((time.perf_counter() - t0) * 1000) + results.append( + _measure(samples, n, 0, "eventbus_wildcard_matching", self.CATEGORY) + ) + + return results + + async def bench_hookmanager(self, n: int = 1000) -> list[BenchResult]: + from xcore.kernel.events.hooks import HookManager + + results = [] + hm = HookManager() + + # 5 hooks prioritaires + for prio in (10, 30, 50, 70, 90): + + @hm.on("test.event", priority=prio) + def _sync_h(event, _p=prio): + return _p + + samples: list[float] = [] + for _ in range(n): + t0 = time.perf_counter() + await hm.emit("test.event", {"data": 1}) + samples.append((time.perf_counter() - t0) * 1000) + results.append( + _measure(samples, n, 0, "hookmanager_5_priority_hooks", self.CATEGORY) + ) + + return results + + +# ╔══════════════════════════════════════════════════════════════════════════╗ +# ║ Section 5 — PermissionEngine ║ +# ╚══════════════════════════════════════════════════════════════════════════╝ + + +class PermissionsBench: + CATEGORY = "permissions" + + def bench_engine(self, n: int = 50000) -> list[BenchResult]: + from xcore.kernel.permissions.engine import PermissionEngine + + engine = PermissionEngine() + permissions = [ + {"resource": "db.*", "actions": ["read", "write"], "effect": "allow"}, + {"resource": "cache.*", "actions": ["*"], "effect": "allow"}, + {"resource": "os.*", "actions": ["*"], "effect": "deny"}, + {"resource": "fs.tmp.*", "actions": ["write"], "effect": "allow"}, + {"resource": "api.v1.*", "actions": ["call"], "effect": "allow"}, + {"resource": "*", "actions": ["read"], "effect": "allow"}, + ] + engine.load_from_manifest("bench_plugin", permissions) + engine.grant_all("bench_plugin") + + test_cases = [ + ("db.users", "read"), + ("cache.items", "write"), + ("os.path", "read"), + ("fs.tmp.file", "write"), + ("api.v1.login", "call"), + ("unknown", "write"), + ] + + results = [] + + # Sans cache (cold) + engine._cache.clear() + samples: list[float] = [] + for i in range(n): + res, act = test_cases[i % len(test_cases)] + engine._cache.clear() # force cold + t0 = time.perf_counter() + engine.allows("bench_plugin", res, act) + samples.append((time.perf_counter() - t0) * 1000) + results.append(_measure(samples, n, 0, "permission_check_cold", self.CATEGORY)) + + # Avec cache (warm) — pré-chauffe + for res, act in test_cases: + engine.allows("bench_plugin", res, act) + samples = [] + for i in range(n): + res, act = test_cases[i % len(test_cases)] + t0 = time.perf_counter() + engine.allows("bench_plugin", res, act) + samples.append((time.perf_counter() - t0) * 1000) + results.append( + _measure(samples, n, 0, "permission_check_cached", self.CATEGORY) + ) + + speedup = ( + results[0].mean_ms / results[1].mean_ms if results[1].mean_ms > 0 else 1 + ) + print(f" → Cache speedup: {speedup:.1f}x") + return results + + +# ╔══════════════════════════════════════════════════════════════════════════╗ +# ║ Section 6 — CacheService ║ +# ╚══════════════════════════════════════════════════════════════════════════╝ + + +class CacheBench: + CATEGORY = "cache" + + async def bench_memory_backend(self, n: int = 5000) -> list[BenchResult]: + from xcore.services.cache.backends.memory import MemoryBackend + + backend = MemoryBackend(ttl=300, max_size=100_000) + results = [] + + # SET + samples: list[float] = [] + for i in range(n): + t0 = time.perf_counter() + await backend.set(f"key:{i}", {"value": i, "data": list(range(5))}) + samples.append((time.perf_counter() - t0) * 1000) + results.append(_measure(samples, n, 0, "cache_set_single", self.CATEGORY)) + + # GET (hot) + samples = [] + for i in range(n): + t0 = time.perf_counter() + await backend.get(f"key:{i % 1000}") + samples.append((time.perf_counter() - t0) * 1000) + results.append(_measure(samples, n, 0, "cache_get_hot", self.CATEGORY)) + + # MSET + mapping = {f"mkey:{i}": {"v": i} for i in range(100)} + samples = [] + for _ in range(n // 10): + t0 = time.perf_counter() + await backend.mset(mapping) + samples.append((time.perf_counter() - t0) * 1000) + results.append( + _measure(samples, n // 10, 0, "cache_mset_100_keys", self.CATEGORY) + ) + + # MGET + keys = [f"mkey:{i}" for i in range(100)] + samples = [] + for _ in range(n // 10): + t0 = time.perf_counter() + await backend.mget(keys) + samples.append((time.perf_counter() - t0) * 1000) + results.append( + _measure(samples, n // 10, 0, "cache_mget_100_keys", self.CATEGORY) + ) + + return results + + +# ╔══════════════════════════════════════════════════════════════════════════╗ +# ║ Section 7 — Capacity Simulation ║ +# ╚══════════════════════════════════════════════════════════════════════════╝ + + +class CapacityBench: + """Simule le chargement de N plugins pour trouver la limite pratique.""" + + async def run(self, max_plugins: int = 150, step: int = 25) -> dict[str, Any]: + print(f"\n{'=' * 60}") + print(f" CAPACITY TEST — jusqu'à {max_plugins} plugins") + print(f"{'=' * 60}") + + results: dict[str, Any] = {} + levels = list(range(step, max_plugins + 1, step)) + if max_plugins not in levels: + levels.append(max_plugins) + + for n in levels: + tmp = Path(tempfile.mkdtemp(prefix="xcore_cap_")) + try: + for i in range(n): + _make_plugin_dir(tmp / "plugins", f"cap_{i:04d}") + + cfg = _make_xcore_config(tmp / "plugins") + mem_bef = _mem_mb() + t0 = time.perf_counter() + + from xcore import Xcore + + app = Xcore(config_path=cfg) + await app.boot() + load_ms = (time.perf_counter() - t0) * 1000 + mem_aft = _mem_mb() + loaded = len(app.plugins.list_plugins()) + + # appels concurrents sur tous les plugins + all_plugins = app.plugins.list_plugins() + t1 = time.perf_counter() + tasks = [ + app.plugins.call(p, "ping", {}) + # cap 200 concurrent + for p in all_plugins[: min(loaded, 200)] + ] + raw = await asyncio.gather(*tasks, return_exceptions=True) + call_ms = (time.perf_counter() - t1) * 1000 + ok_calls = sum( + 1 for r in raw if isinstance(r, dict) and r.get("status") == "ok" + ) + + results[n] = { + "n_requested": n, + "n_loaded": loaded, + "load_time_ms": round(load_ms, 1), + "ms_per_plugin": round(load_ms / loaded, 2) if loaded else 0, + "mem_delta_mb": round(mem_aft - mem_bef, 2), + "mb_per_plugin": ( + round((mem_aft - mem_bef) / loaded, 3) if loaded else 0 + ), + "concurrent_calls_ok": ok_calls, + "concurrent_call_ms": round(call_ms, 1), + } + print( + f" N={n:4d} | loaded={loaded:4d} | " + f"load={load_ms:7.1f}ms ({load_ms / loaded:.1f}ms/p) | " + f"mem={mem_aft - mem_bef:.1f}MB " + f"({(mem_aft - mem_bef) / loaded:.2f}MB/p) | " + f"concurrent_ok={ok_calls}/{len(tasks)}" + ) + + await app.shutdown() + except MemoryError: + print(f" N={n}: MemoryError — limite atteinte") + results[n] = {"error": "MemoryError"} + break + except Exception as e: + print(f" N={n}: ERROR — {e}") + results[n] = {"error": str(e)} + finally: + shutil.rmtree(tmp, ignore_errors=True) + gc.collect() + await asyncio.sleep(0.05) + + return results + + +# ╔══════════════════════════════════════════════════════════════════════════╗ +# ║ Helpers internes ║ +# ╚══════════════════════════════════════════════════════════════════════════╝ + + +def _make_xcore_instance(cfg: str): + """ + Retourne une instance Xcore fraîche (sans cache — isolation garantie). + + FIX: renommé depuis `Xcore_cached` (non-PEP8, trompeur) ; + suppression du dict `_xcore_cache` qui était déclaré mais jamais utilisé. + """ + from xcore import Xcore + + return Xcore(config_path=cfg) + + +class _MockServices: + def as_dict(self) -> dict: + return {} + + def has(self, n: str) -> bool: + return False + + def get(self, n: str): + raise KeyError(n) + + +def _make_ctx(services: _MockServices): + from unittest.mock import MagicMock + + from xcore.kernel.context import KernelContext + + ctx = KernelContext( + config=MagicMock( + directory="/tmp", + strict_trusted=False, + secret_key=b"test", + interval=0, + entry_point="src/main.py", + ), + services=services, + events=MagicMock(), + hooks=MagicMock(), + registry=MagicMock(), + metrics=MagicMock(), + tracer=MagicMock(), + health=MagicMock(), + ) + ctx.services = services + return ctx + + +# ╔══════════════════════════════════════════════════════════════════════════╗ +# ║ Runner principal ║ +# ╚══════════════════════════════════════════════════════════════════════════╝ + + +async def run_suite(args) -> BenchReport: + import datetime + + from xcore import __version__ + + report = BenchReport( + xcore_version=__version__, + timestamp=datetime.datetime.now().isoformat(), + ) + + if _HAS_PSUTIL: + report.system_info = { + "cpu_count": psutil.cpu_count(), + "cpu_freq_mhz": psutil.cpu_freq().current if psutil.cpu_freq() else "N/A", + "total_ram_gb": round(psutil.virtual_memory().total / 1024**3, 1), + "available_ram_gb": round(psutil.virtual_memory().available / 1024**3, 1), + } + + suites_to_run = ( + args.suite + if args.suite + else ["lifecycle", "calls", "middleware", "events", "permissions", "cache"] + ) + + t_global = time.perf_counter() + + # ── 1. Lifecycle ────────────────────────────────────────── + if "lifecycle" in suites_to_run or "plugins" in suites_to_run: + print("\n[1/7] Plugin Lifecycle benchmarks...") + bench = PluginLifecycleBench() + for r in await bench.bench_single_load_unload(n_runs=args.runs): + report.add(r) + print(f" ✓ {r.name}: mean={r.mean_ms:.2f}ms, p99={r.p99_ms:.2f}ms") + for r in await bench.bench_reload(n_runs=args.runs): + report.add(r) + print(f" ✓ {r.name}: mean={r.mean_ms:.2f}ms") + for r in await bench.bench_batch_load(batch_sizes=[5, 20, 50]): + report.add(r) + print(f" ✓ {r.name}: {r.total_ms:.0f}ms total, {r.notes}") + + # ── 2. Calls ────────────────────────────────────────────── + if "calls" in suites_to_run: + print("\n[2/7] Plugin Call Throughput benchmarks...") + bench = PluginCallBench() + for r in await bench.bench_sequential_calls(n_calls=args.calls): + report.add(r) + print( + f" ✓ {r.name}: mean={r.mean_ms:.3f}ms, {r.throughput_ops_sec:.0f} ops/s" + ) + for r in await bench.bench_concurrent_calls([10, 50, 100]): + report.add(r) + print(f" ✓ {r.name}: {r.throughput_ops_sec:.0f} ops/s") + for r in await bench.bench_multi_plugin_routing([5, 20]): + report.add(r) + print(f" ✓ {r.name}: mean={r.mean_ms:.3f}ms") + + # ── 3. Middleware ───────────────────────────────────────── + if "middleware" in suites_to_run: + print("\n[3/7] Middleware Pipeline benchmarks...") + bench = MiddlewareBench() + for r in await bench.bench_pipeline_overhead(n=2000): + report.add(r) + print( + f" ✓ {r.name}: mean={r.mean_ms:.4f}ms, {r.throughput_ops_sec:.0f} ops/s" + ) + + # ── 4. Events ───────────────────────────────────────────── + if "events" in suites_to_run: + print("\n[4/7] EventBus / HookManager benchmarks...") + bench = EventsBench() + for r in await bench.bench_eventbus(n=2000): + report.add(r) + print( + f" ✓ {r.name}: mean={r.mean_ms:.4f}ms, {r.throughput_ops_sec:.0f} ops/s" + ) + for r in await bench.bench_hookmanager(n=1000): + report.add(r) + print(f" ✓ {r.name}: mean={r.mean_ms:.4f}ms") + + # ── 5. Permissions ──────────────────────────────────────── + if "permissions" in suites_to_run: + print("\n[5/7] PermissionEngine benchmarks...") + bench = PermissionsBench() + for r in bench.bench_engine(n=30000): + report.add(r) + print( + f" ✓ {r.name}: mean={r.mean_ms:.5f}ms, {r.throughput_ops_sec:.0f} ops/s" + ) + + # ── 6. Cache ────────────────────────────────────────────── + if "cache" in suites_to_run: + print("\n[6/7] CacheService (memory backend) benchmarks...") + bench = CacheBench() + for r in await bench.bench_memory_backend(n=3000): + report.add(r) + print( + f" ✓ {r.name}: mean={r.mean_ms:.4f}ms, {r.throughput_ops_sec:.0f} ops/s" + ) + + # ── 7. Capacity ─────────────────────────────────────────── + if "capacity" in suites_to_run or args.capacity: + max_p = args.capacity if args.capacity else 100 + step = max(10, max_p // 10) + print(f"\n[7/7] Capacity simulation (max={max_p}, step={step})...") + cap_bench = CapacityBench() + report.capacity = await cap_bench.run(max_plugins=max_p, step=step) + + report.total_duration_s = time.perf_counter() - t_global + return report + + +def print_report(report: BenchReport) -> None: + w = 80 + print(f"\n{'═' * w}") + print(f" XCORE PERFORMANCE REPORT — v{report.xcore_version}") + print( + f" Python {report.python_version} | {report.platform} | " + f"{report.cpu_count} CPUs | {report.timestamp}" + ) + if report.system_info: + si = report.system_info + print( + f" RAM: {si.get('total_ram_gb')}GB total, " + f"{si.get('available_ram_gb')}GB available | " + f"CPU: {si.get('cpu_freq_mhz')}MHz" + ) + print(f"{'═' * w}") + + # Regrouper par catégorie + categories: dict[str, list[BenchResult]] = {} + for r in report.results: + categories.setdefault(r.category, []).append(r) + + headers = [ + "Benchmark", + "Mean(ms)", + "Med(ms)", + "P95(ms)", + "P99(ms)", + "Min(ms)", + "Max(ms)", + "Ops/s", + "ΔMem(MB)", + "Status", + ] + + for cat, items in categories.items(): + print(f"\n ┌── {cat.upper()} {'─' * (w - 7 - len(cat))}") + rows = [r.as_row() for r in items] + if _HAS_TABULATE: + print(tabulate(rows, headers=headers, tablefmt="simple")) + else: + print(" " + " ".join(f"{h:<16}" for h in headers)) + for row in rows: + print(" " + " ".join(f"{str(c):<16}" for c in row)) + + # Capacity summary + if report.capacity: + print(f"\n ┌── CAPACITY SIMULATION {'─' * (w - 25)}") + cap_headers = [ + "N", + "Loaded", + "Load(ms)", + "ms/plugin", + "ΔMem(MB)", + "MB/plugin", + "Concurrent OK", + ] + cap_rows = [] + for n, data in sorted(report.capacity.items()): + if "error" in data: + cap_rows.append([n, "ERROR", "-", "-", "-", "-", data["error"]]) + else: + cap_rows.append( + [ + data["n_requested"], + f"{data['n_loaded']}/{data['n_requested']}", + f"{data['load_time_ms']:.0f}", + f"{data['ms_per_plugin']:.1f}", + f"{data['mem_delta_mb']:.1f}", + f"{data['mb_per_plugin']:.3f}", + f"{data['concurrent_calls_ok']}", + ] + ) + if _HAS_TABULATE: + print(tabulate(cap_rows, headers=cap_headers, tablefmt="simple")) + else: + print(" " + " ".join(f"{h:<15}" for h in cap_headers)) + for row in cap_rows: + print(" " + " ".join(f"{str(c):<15}" for c in row)) + + # extrapolation + valid = [ + (n, d) + for n, d in report.capacity.items() + if isinstance(d, dict) + and "error" not in d + and d.get("mb_per_plugin", 0) > 0 + ] + if valid: + avg_mb = statistics.mean(d["mb_per_plugin"] for _, d in valid) + avg_ms = statistics.mean(d["ms_per_plugin"] for _, d in valid) + if _HAS_PSUTIL: + avail_mb = psutil.virtual_memory().available / 1024**2 + theoretical_max = int(avail_mb / avg_mb) + print(f"\n Extrapolation:") + print( + f" Average {avg_mb:.3f}MB/plugin — " + f"{avail_mb:.0f}MB available → " + f"≈ {theoretical_max} plugins theoretically supportable" + ) + print(f" Average {avg_ms:.1f}ms/plugin load time") + + print(f"\n Total benchmark duration: {report.total_duration_s:.1f}s") + print(f"{'═' * w}") + + # Recommandations + print("\n INSIGHTS:") + all_results = {r.name: r for r in report.results} + + if "sequential_call_ping" in all_results: + r = all_results["sequential_call_ping"] + print( + f" • Plugin call latency (ping): {r.mean_ms:.3f}ms mean, " + f"{r.p99_ms:.3f}ms P99 — " + f"{'✅ excellent' if r.mean_ms < 1 else '⚠ acceptable' if r.mean_ms < 5 else '❌ slow'}" + ) + + if ( + "permission_check_cold" in all_results + and "permission_check_cached" in all_results + ): + cold = all_results["permission_check_cold"].mean_ms + cached = all_results["permission_check_cached"].mean_ms + speedup = cold / cached if cached > 0 else 1 + print( + f" • Permission cache speedup: {speedup:.1f}x " + f"({cold:.5f}ms cold → {cached:.5f}ms cached)" + ) + + if ( + "pipeline_0_middlewares" in all_results + and "pipeline_4_middlewares" in all_results + ): + overhead = ( + all_results["pipeline_4_middlewares"].mean_ms + - all_results["pipeline_0_middlewares"].mean_ms + ) + print(f" • Middleware overhead (4 layers): {overhead:.4f}ms/call") + + for name in ["eventbus_emit_no_handlers", "eventbus_emit_10_handlers"]: + if name in all_results: + r = all_results[name] + print(f" • {name}: {r.throughput_ops_sec:.0f} ops/s") + + +def save_report(report: BenchReport, path: str) -> None: + def _serialize(obj): + if isinstance(obj, BenchResult): + return asdict(obj) + return str(obj) + + data = { + "meta": { + "xcore_version": report.xcore_version, + "python_version": report.python_version, + "platform": report.platform, + "timestamp": report.timestamp, + "cpu_count": report.cpu_count, + "total_duration_s": report.total_duration_s, + "system_info": report.system_info, + }, + "results": [asdict(r) for r in report.results], + "capacity": {str(k): v for k, v in report.capacity.items()}, + } + with open(path, "w") as f: + json.dump(data, f, indent=2, default=_serialize) + print(f"\n Report saved → {path}") + + +# ╔══════════════════════════════════════════════════════════════════════════╗ +# ║ Entry point ║ +# ╚══════════════════════════════════════════════════════════════════════════╝ + + +def main(): + parser = argparse.ArgumentParser(description="xcore Performance Benchmark") + parser.add_argument( + "--suite", + nargs="+", + choices=[ + "lifecycle", + "plugins", + "calls", + "middleware", + "events", + "permissions", + "cache", + "capacity", + ], + help="Suites à exécuter (défaut: toutes sauf capacity)", + ) + parser.add_argument( + "--capacity", + type=int, + default=0, + help="Activer capacity test jusqu'à N plugins (ex: 200)", + ) + parser.add_argument( + "--runs", + type=int, + default=20, + help="Nombre de runs pour les tests lifecycle (défaut: 20)", + ) + parser.add_argument( + "--calls", + type=int, + default=300, + help="Nombre d'appels pour les tests throughput (défaut: 300)", + ) + parser.add_argument( + "--output", + type=str, + default="", + help="Sauvegarder le rapport JSON dans ce fichier", + ) + args = parser.parse_args() + + print("xcore Benchmark Suite — starting...") + if not _HAS_PSUTIL: + print(" [INFO] Install psutil for memory metrics: pip install psutil") + if not _HAS_TABULATE: + print(" [INFO] Install tabulate for better tables: pip install tabulate") + + report = asyncio.run(run_suite(args)) + print_report(report) + + output_path = args.output or "xcore_bench_report.json" + save_report(report, output_path) + return report + + +if __name__ == "__main__": + main() diff --git a/tests/benchmarks/test_kernel_benchmarks.py b/tests/benchmarks/test_kernel_benchmarks.py new file mode 100644 index 00000000..f9f94c4a --- /dev/null +++ b/tests/benchmarks/test_kernel_benchmarks.py @@ -0,0 +1,98 @@ +import pytest + +from xcore.kernel.permissions.engine import PermissionEngine +from xcore.kernel.permissions.policies import Policy, PolicyEffect + + +@pytest.mark.benchmark(group="policy-matching") +def test_policy_match_success(benchmark): + """Benchmark un match réussi de policy.""" + policy = Policy(resource="db.*", actions=["read", "write"]) + resource = "db.users" + action = "read" + + result = benchmark(policy.matches, resource, action) + assert result is True + + +@pytest.mark.benchmark(group="policy-matching") +def test_policy_match_failure_action(benchmark): + """Benchmark un échec de match (action incorrecte).""" + policy = Policy(resource="db.*", actions=["read", "write"]) + resource = "db.users" + action = "delete" + + result = benchmark(policy.matches, resource, action) + assert result is False + + +@pytest.mark.benchmark(group="policy-matching") +def test_policy_match_failure_resource(benchmark): + """Benchmark un échec de match (ressource incorrecte).""" + policy = Policy(resource="db.*", actions=["read", "write"]) + resource = "other.resource" + action = "read" + + result = benchmark(policy.matches, resource, action) + assert result is False + + +def run_permission_check(engine, plugin_name, resources_actions): + for res, act in resources_actions: + engine.allows(plugin_name, res, act) + + +@pytest.mark.benchmark(group="permission-engine") +def test_permission_engine_evaluation(benchmark): + """Benchmark de l'évaluation globale par le PermissionEngine.""" + engine = PermissionEngine() + permissions = [ + {"resource": "db.users.*", "actions": ["read"], "effect": "allow"}, + {"resource": "db.admin.*", "actions": ["*"], "effect": "deny"}, + {"resource": "cache.*", "actions": ["read", "write"], "effect": "allow"}, + {"resource": "fs.tmp.*", "actions": ["write"], "effect": "allow"}, + {"resource": "api.v1.*", "actions": ["call"], "effect": "allow"}, + ] + engine.load_from_manifest("test_plugin", permissions) + + resources_actions = [ + ("db.users.profile", "read"), + ("db.admin.secrets", "read"), + ("cache.data", "write"), + ("fs.tmp.file1", "write"), + ("api.v1.login", "call"), + ("unknown.resource", "read"), + ] + + # On vide le cache avant chaque tour si nécessaire, + # mais ici on veut tester la performance RÉELLE (incluant le cache) + benchmark(run_permission_check, engine, "test_plugin", resources_actions) + + +@pytest.mark.benchmark(group="permission-engine") +def test_permission_engine_no_cache(benchmark): + """Benchmark de l'évaluation sans cache (pire cas).""" + engine = PermissionEngine() + permissions = [ + {"resource": "db.users.*", "actions": ["read"], "effect": "allow"}, + {"resource": "db.admin.*", "actions": ["*"], "effect": "deny"}, + {"resource": "cache.*", "actions": ["read", "write"], "effect": "allow"}, + {"resource": "fs.tmp.*", "actions": ["write"], "effect": "allow"}, + {"resource": "api.v1.*", "actions": ["call"], "effect": "allow"}, + ] + engine.load_from_manifest("test_plugin", permissions) + + resources_actions = [ + ("db.users.profile", "read"), + ("db.admin.secrets", "read"), + ("cache.data", "write"), + ("fs.tmp.file1", "write"), + ("api.v1.login", "call"), + ("unknown.resource", "read"), + ] + + def run_with_clear_cache(): + engine._cache.clear() + run_permission_check(engine, "test_plugin", resources_actions) + + benchmark(run_with_clear_cache) diff --git a/tests/benchmarks/test_permission_bench.py b/tests/benchmarks/test_permission_bench.py index ab4a88b9..09b9cbce 100644 --- a/tests/benchmarks/test_permission_bench.py +++ b/tests/benchmarks/test_permission_bench.py @@ -40,8 +40,7 @@ def __init__(self) -> None: def load_from_manifest(self, plugin_name, raw_permissions): policies = [ - Policy(p["resource"], p["actions"], - PolicyEffect(p.get("effect", "allow"))) + Policy(p["resource"], p["actions"], PolicyEffect(p.get("effect", "allow"))) for p in raw_permissions ] self._policies[plugin_name] = PolicySet(plugin_name, policies) @@ -98,8 +97,7 @@ def benchmark_permissions(): permissions = [ {"resource": "db.users.*", "actions": ["read"], "effect": "allow"}, {"resource": "db.admin.*", "actions": ["*"], "effect": "deny"}, - {"resource": "cache.*", - "actions": ["read", "write"], "effect": "allow"}, + {"resource": "cache.*", "actions": ["read", "write"], "effect": "allow"}, {"resource": "fs.tmp.*", "actions": ["write"], "effect": "allow"}, {"resource": "api.v1.*", "actions": ["call"], "effect": "allow"}, ] @@ -124,8 +122,7 @@ def run_benchmark(e): t1 = timeit.Timer(lambda: run_benchmark(engine)).timeit(number=iterations) print(f"Original - Time for {iterations} iterations: {t1:.4f}s") - t2 = timeit.Timer(lambda: run_benchmark( - engine_opt)).timeit(number=iterations) + t2 = timeit.Timer(lambda: run_benchmark(engine_opt)).timeit(number=iterations) print(f"Optimized (Cached) - Time for {iterations} iterations: {t2:.4f}s") improvement = (t1 - t2) / t1 * 100 diff --git a/tests/benchmarks/test_permission_bench_real.py b/tests/benchmarks/test_permission_bench_real.py index 761304b5..49fed2b4 100644 --- a/tests/benchmarks/test_permission_bench_real.py +++ b/tests/benchmarks/test_permission_bench_real.py @@ -12,8 +12,7 @@ def benchmark_permissions(): permissions = [ {"resource": "db.users.*", "actions": ["read"], "effect": "allow"}, {"resource": "db.admin.*", "actions": ["*"], "effect": "deny"}, - {"resource": "cache.*", - "actions": ["read", "write"], "effect": "allow"}, + {"resource": "cache.*", "actions": ["read", "write"], "effect": "allow"}, {"resource": "fs.tmp.*", "actions": ["write"], "effect": "allow"}, {"resource": "api.v1.*", "actions": ["call"], "effect": "allow"}, {"resource": "internal.*", "actions": ["*"], "effect": "deny"}, diff --git a/tests/conftest.py b/tests/conftest.py index c4f16143..bac4fa90 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -95,45 +95,47 @@ def plugins_dir() -> Generator: def fake_plugin_dir(plugins_dir: Path) -> Path: """ Crée un plugin factice valide dans le dossier temporaire. - Structure minimale respectant le contrat xcore. + Structure v2 respectant le contrat xcore. """ - plugin = plugins_dir / "fake_plugin" + plugin = plugins_dir / "test_plugin" plugin.mkdir() - (plugin / "__init__.py").write_text( - "from .run import Plugin, router\n__all__ = ['Plugin', 'router']\n" - ) + src = plugin / "src" + src.mkdir() - (plugin / "run.py").write_text( - "from fastapi import APIRouter, Request\n\n" - "PLUGIN_INFO = {\n" - " 'version': '1.0.0',\n" - " 'author': 'test',\n" - " 'description': 'Plugin de test',\n" - " 'Api_prefix': '/app/fake',\n" - " 'tag_for_identified': ['fake'],\n" - "}\n\n" - "router = APIRouter(prefix='/fake', tags=['fake'])\n\n" - "class Plugin:\n" - " def __init__(self):\n" - " super(Plugin, self).__init__()\n\n" + (src / "__init__.py").write_text("") + + (src / "main.py").write_text( + "from fastapi import APIRouter, Request\n" + "from xcore import TrustedBase\n\n" + "router = APIRouter()\n\n" + "class Plugin(TrustedBase):\n" + " def __init__(self, ctx, caller):\n" + " super().__init__(ctx, caller)\n\n" " @router.get('/')\n" - " @staticmethod\n" - " def index(request: Request):\n" - " return {'status': 'ok', 'plugin': 'fake'}\n" + " async def index(self, request: Request):\n" + " return {'status': 'ok', 'plugin': 'test'}\n" ) - (plugin / "config.yaml").write_text( - "name: fake_plugin\n" + (plugin / "plugin.yaml").write_text( + "name: test_plugin\n" "version: '1.0.0'\n" "author: test\n" - "enabled: true\n" - "api_prefix: /app/fake\n" + "execution_mode: trusted\n" + "entry_point: src/main.py\n" ) return plugin +@pytest.fixture +def temp_dir() -> Generator[Path, None, None]: + """Fixture utilitaire pour un dossier temporaire propre.""" + tmp = tempfile.mkdtemp() + yield Path(tmp) + shutil.rmtree(tmp, ignore_errors=True) + + @pytest.fixture def invalid_plugin_dir(plugins_dir: Path) -> Path: """Plugin invalide — manque PLUGIN_INFO.""" @@ -232,8 +234,7 @@ def mock_redis(): def test_user(db_session): """Crée un utilisateur de test en base.""" try: - from extensions.services.database import \ - User # adapter selon ton modèle + from extensions.services.database import User # adapter selon ton modèle from extensions.services.security import hash_password user = User( diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py index e0176e65..042dd0ea 100644 --- a/tests/fixtures/__init__.py +++ b/tests/fixtures/__init__.py @@ -19,26 +19,30 @@ def sample_plugin_dir(): plugin_dir.mkdir() # Create plugin.yaml - (plugin_dir / "plugin.yaml").write_text(""" + (plugin_dir / "plugin.yaml").write_text( + """ name: sample_plugin version: 1.0.0 author: Test execution_mode: trusted entry_point: src/main.py -""") +""" + ) # Create src directory src_dir = plugin_dir / "src" src_dir.mkdir() # Create main.py - (src_dir / "main.py").write_text(""" + (src_dir / "main.py").write_text( + """ from xcore.sdk import TrustedBase, ok class Plugin(TrustedBase): async def handle(self, action, payload): return ok(message="Hello from sample plugin") -""") +""" + ) yield plugin_dir diff --git a/tests/integration/test_plugin_lifecycle.py b/tests/integration/test_plugin_lifecycle.py index a7d27247..1ae2b53c 100644 --- a/tests/integration/test_plugin_lifecycle.py +++ b/tests/integration/test_plugin_lifecycle.py @@ -19,7 +19,8 @@ async def xcore_app(self): with tempfile.TemporaryDirectory() as tmp: # Create minimal config config_path = Path(tmp) / "test.yaml" - config_path.write_text(""" + config_path.write_text( + """ app: name: test-app secret_key: test-secret-key-for-testing-min32chars @@ -34,7 +35,8 @@ async def xcore_app(self): cache: backend: memory ttl: 300 -""") +""" + ) app = Xcore(config_path=str(config_path)) await app.boot() @@ -83,7 +85,8 @@ async def test_plugin_list_empty(self, xcore_app): status = xcore_app.plugins.status() assert "plugins" in status - assert status["count"] == 0 + # xcore itself is registered as a handler + assert status["count"] == 1 @pytest.mark.asyncio async def test_health_check(self, xcore_app): diff --git a/tests/integration/test_xcore.py b/tests/integration/test_xcore.py index b4dcd74a..ff5cb2eb 100644 --- a/tests/integration/test_xcore.py +++ b/tests/integration/test_xcore.py @@ -109,20 +109,27 @@ def config_with_plugins(self, tmp_path): src_dir.mkdir() # plugin.yaml - (test_plugin / "plugin.yaml").write_text(""" + (test_plugin / "plugin.yaml").write_text( + """ name: test_plugin version: 1.0.0 execution_mode: trusted -""") +permissions: + - resource: "*" + actions: ["*"] +""" + ) # main.py - (src_dir / "main.py").write_text(""" + (src_dir / "main.py").write_text( + """ from xcore.sdk import TrustedBase, ok class Plugin(TrustedBase): async def handle(self, action, payload): return ok(message="pong") -""") +""" + ) config_content = f""" app: @@ -131,6 +138,7 @@ async def handle(self, action, payload): plugins: directory: {plugins_dir} + strict_trusted: false services: databases: {{}} diff --git a/tests/unit/kernel/test_dynamic_middleware.py b/tests/unit/kernel/test_dynamic_middleware.py index 77c65017..75f73bb0 100644 --- a/tests/unit/kernel/test_dynamic_middleware.py +++ b/tests/unit/kernel/test_dynamic_middleware.py @@ -1,4 +1,3 @@ -import asyncio from unittest.mock import AsyncMock, MagicMock import pytest @@ -12,7 +11,9 @@ def __init__(self, name, calls): self.name = name self.calls = calls - async def __call__(self, plugin_name, action, payload, next_call, handler=None, **kwargs): + async def __call__( + self, plugin_name, action, payload, next_call, handler=None, **kwargs + ): self.calls.append(f"before:{self.name}") res = await next_call(plugin_name, action, payload, handler, **kwargs) self.calls.append(f"after:{self.name}") @@ -22,6 +23,7 @@ async def __call__(self, plugin_name, action, payload, next_call, handler=None, @pytest.mark.asyncio async def test_dynamic_middleware_registration(): from xcore.kernel.context import KernelContext + config = MagicMock() config.plugins.directory = "plugins" services = MagicMock() @@ -47,6 +49,7 @@ async def test_dynamic_middleware_registration(): supervisor._loader.get.return_value = handler from xcore.kernel.runtime.middlewares import MiddlewarePipeline + supervisor._pipeline = MiddlewarePipeline([], supervisor._dispatch) calls = [] diff --git a/tests/unit/kernel/test_events_wildcard.py b/tests/unit/kernel/test_events_wildcard.py index f2dae050..bc08cf65 100644 --- a/tests/unit/kernel/test_events_wildcard.py +++ b/tests/unit/kernel/test_events_wildcard.py @@ -1,5 +1,3 @@ -import asyncio - import pytest from xcore.kernel.events.bus import EventBus diff --git a/tests/unit/kernel/test_lifecycle.py b/tests/unit/kernel/test_lifecycle.py index d816bb2c..f66e90c4 100644 --- a/tests/unit/kernel/test_lifecycle.py +++ b/tests/unit/kernel/test_lifecycle.py @@ -100,6 +100,7 @@ def shared_services(): def lifecycle_manager(mock_manifest, shared_services): """Create a LifecycleManager instance.""" from xcore.kernel.context import KernelContext + ctx = KernelContext( config=MagicMock(), services=MagicMock(), @@ -154,11 +155,13 @@ async def test_load_invalid_plugin_contract(self, lifecycle_manager, tmp_path): """Test load raises error when plugin doesn't implement BasePlugin.""" src_dir = tmp_path / "src" src_dir.mkdir() - (src_dir / "main.py").write_text(""" + (src_dir / "main.py").write_text( + """ class Plugin: def __init__(self): pass -""") +""" + ) with pytest.raises(LoadError) as exc_info: await lifecycle_manager.load() @@ -170,7 +173,8 @@ async def test_load_success(self, lifecycle_manager, tmp_path): """Test successful plugin load.""" src_dir = tmp_path / "src" src_dir.mkdir() - (src_dir / "main.py").write_text(""" + (src_dir / "main.py").write_text( + """ from xcore.kernel.api.contract import BasePlugin class Plugin(BasePlugin): @@ -179,7 +183,8 @@ async def handle(self, action, payload): async def on_load(self): self.loaded = True -""") +""" + ) await lifecycle_manager.load() @@ -193,7 +198,8 @@ async def test_load_calls_on_load(self, lifecycle_manager, tmp_path): """Test that on_load hook is called.""" src_dir = tmp_path / "src" src_dir.mkdir() - (src_dir / "main.py").write_text(""" + (src_dir / "main.py").write_text( + """ from xcore.kernel.api.contract import BasePlugin class Plugin(BasePlugin): @@ -205,7 +211,8 @@ async def handle(self, action, payload): async def on_load(self): self.on_load_called = True -""") +""" + ) await lifecycle_manager.load() assert lifecycle_manager._instance.on_load_called is True @@ -215,13 +222,15 @@ async def test_call_action(self, lifecycle_manager, tmp_path): """Test calling an action on the plugin.""" src_dir = tmp_path / "src" src_dir.mkdir() - (src_dir / "main.py").write_text(""" + (src_dir / "main.py").write_text( + """ from xcore.kernel.api.contract import BasePlugin class Plugin(BasePlugin): async def handle(self, action, payload): return {"status": "ok", "action": action} -""") +""" + ) await lifecycle_manager.load() result = await lifecycle_manager.call("test_action", {"key": "value"}) @@ -242,7 +251,8 @@ async def test_call_timeout(self, lifecycle_manager, tmp_path): """Test call handles timeout.""" src_dir = tmp_path / "src" src_dir.mkdir() - (src_dir / "main.py").write_text(""" + (src_dir / "main.py").write_text( + """ import asyncio from xcore.kernel.api.contract import BasePlugin @@ -250,7 +260,8 @@ class Plugin(BasePlugin): async def handle(self, action, payload): await asyncio.sleep(10) # Longer than timeout return {"status": "ok"} -""") +""" + ) lifecycle_manager.manifest.resources.timeout_seconds = 0.1 await lifecycle_manager.load() @@ -264,7 +275,8 @@ async def test_concurrent_calls(self, lifecycle_manager, tmp_path): """Test that multiple calls can be made concurrently.""" src_dir = tmp_path / "src" src_dir.mkdir() - (src_dir / "main.py").write_text(""" + (src_dir / "main.py").write_text( + """ import asyncio from xcore.kernel.api.contract import BasePlugin @@ -272,14 +284,14 @@ class Plugin(BasePlugin): async def handle(self, action, payload): await asyncio.sleep(0.1) return {"status": "ok", "action": action} -""") +""" + ) lifecycle_manager.manifest.resources.timeout_seconds = 1 await lifecycle_manager.load() results = await asyncio.gather( - lifecycle_manager.call( - "ping1", {}), lifecycle_manager.call("ping2", {}) + lifecycle_manager.call("ping1", {}), lifecycle_manager.call("ping2", {}) ) assert len(results) == 2 @@ -291,7 +303,8 @@ async def test_reload(self, lifecycle_manager, tmp_path): """Test plugin reload.""" src_dir = tmp_path / "src" src_dir.mkdir() - (src_dir / "main.py").write_text(""" + (src_dir / "main.py").write_text( + """ from xcore.kernel.api.contract import BasePlugin class Plugin(BasePlugin): @@ -300,7 +313,8 @@ async def handle(self, action, payload): async def on_reload(self): self.reloaded = True -""") +""" + ) await lifecycle_manager.load() first_instance = lifecycle_manager._instance @@ -315,7 +329,8 @@ async def test_unload(self, lifecycle_manager, tmp_path): """Test plugin unload.""" src_dir = tmp_path / "src" src_dir.mkdir() - (src_dir / "main.py").write_text(""" + (src_dir / "main.py").write_text( + """ from xcore.kernel.api.contract import BasePlugin class Plugin(BasePlugin): @@ -324,7 +339,8 @@ async def handle(self, action, payload): async def on_unload(self): self.unloaded = True -""") +""" + ) await lifecycle_manager.load() await lifecycle_manager.unload() @@ -337,7 +353,8 @@ async def test_collect_router(self, lifecycle_manager, tmp_path): """Test router collection.""" src_dir = tmp_path / "src" src_dir.mkdir() - (src_dir / "main.py").write_text(""" + (src_dir / "main.py").write_text( + """ from xcore.kernel.api.contract import BasePlugin from unittest.mock import MagicMock @@ -349,7 +366,8 @@ def get_router(self): router = MagicMock() router.routes = [1, 2, 3] return router -""") +""" + ) await lifecycle_manager.load() assert lifecycle_manager.plugin_router is not None @@ -369,13 +387,15 @@ async def test_status_loaded(self, lifecycle_manager, tmp_path): """Test status when plugin is loaded.""" src_dir = tmp_path / "src" src_dir.mkdir() - (src_dir / "main.py").write_text(""" + (src_dir / "main.py").write_text( + """ from xcore.kernel.api.contract import BasePlugin class Plugin(BasePlugin): async def handle(self, action, payload): return {"status": "ok"} -""") +""" + ) await lifecycle_manager.load() status = lifecycle_manager.status() @@ -400,7 +420,7 @@ async def test_propagate_services_new_keys(self, lifecycle_manager, tmp_path): class Plugin(BasePlugin): def __init__(self): super().__init__() - + async def on_load(self,): self._services = {"new_service": "value"} async def handle(self, action, payload): @@ -466,15 +486,13 @@ def test_import_module(self, lifecycle_manager, tmp_path): test_file = tmp_path / "test_module.py" test_file.write_text("test_var = 'hello'") - module = lifecycle_manager._import_module( - "test_module_import", test_file) + module = lifecycle_manager._import_module("test_module_import", test_file) assert module.test_var == "hello" def test_on_state_change(self, lifecycle_manager): """Test state change callback.""" lifecycle_manager._events = MagicMock() - lifecycle_manager._on_state_change( - PluginState.UNLOADED, PluginState.LOADING) + lifecycle_manager._on_state_change(PluginState.UNLOADED, PluginState.LOADING) lifecycle_manager._events.emit_sync.assert_called_once() call_args = lifecycle_manager._events.emit_sync.call_args @@ -489,13 +507,15 @@ async def test_call_non_dict_result(self, lifecycle_manager, tmp_path): """Test call handles non-dict result.""" src_dir = tmp_path / "src" src_dir.mkdir() - (src_dir / "main.py").write_text(""" + (src_dir / "main.py").write_text( + """ from xcore.kernel.api.contract import BasePlugin class Plugin(BasePlugin): async def handle(self, action, payload): return "string_result" # Not a dict -""") +""" + ) await lifecycle_manager.load() result = await lifecycle_manager.call("action", {}) @@ -528,13 +548,15 @@ async def test_unload_cleans_modules(self, lifecycle_manager, tmp_path): """Test unload cleans sys.modules.""" src_dir = tmp_path / "src" src_dir.mkdir() - (src_dir / "main.py").write_text(""" + (src_dir / "main.py").write_text( + """ from xcore.kernel.api.contract import BasePlugin class Plugin(BasePlugin): async def handle(self, action, payload): return {"status": "ok"} -""") +""" + ) await lifecycle_manager.load() module_name = f"xcore_plugin_{lifecycle_manager.manifest.name}" diff --git a/tests/unit/kernel/test_lifecycle_protection.py b/tests/unit/kernel/test_lifecycle_protection.py index 791e90c1..559583bb 100644 --- a/tests/unit/kernel/test_lifecycle_protection.py +++ b/tests/unit/kernel/test_lifecycle_protection.py @@ -28,6 +28,7 @@ def mock_manifest(): async def test_lifecycle_propagate_services_protection(): from xcore.kernel.context import KernelContext from xcore.registry.index import PluginRegistry + shared_services = {"db": "core_db", "cache": "core_cache"} registry = PluginRegistry() # Register "db" as a core service in the registry to protect it @@ -55,7 +56,7 @@ async def test_lifecycle_propagate_services_protection(): with pytest.raises((PermissionError, ValueError)) as exc: lm.propagate_services() - assert "écrasement de services protégés" in str(exc.value) + assert "Impossible d'écraser le service protégé" in str(exc.value) # Vérifier que le service original n'a pas été écrasé dans shared_services # (LifecycleManager ne doit pas atteindre la phase d'update s'il y a collision) assert shared_services["db"] == "core_db" @@ -64,6 +65,7 @@ async def test_lifecycle_propagate_services_protection(): @pytest.mark.asyncio async def test_lifecycle_propagate_services_allowed(): from xcore.kernel.context import KernelContext + shared_services = {"db": "core_db"} # Instance du plugin qui enregistre un nouveau service non protégé diff --git a/tests/unit/kernel/test_middleware_compiled.py b/tests/unit/kernel/test_middleware_compiled.py index 174c9e14..2403cafa 100644 --- a/tests/unit/kernel/test_middleware_compiled.py +++ b/tests/unit/kernel/test_middleware_compiled.py @@ -1,10 +1,8 @@ - from unittest.mock import MagicMock import pytest -from xcore.kernel.runtime.middlewares.middleware import (Middleware, - MiddlewarePipeline) +from xcore.kernel.runtime.middlewares.middleware import Middleware, MiddlewarePipeline class MockMiddleware(Middleware): @@ -12,7 +10,9 @@ def __init__(self, name): self.name = name self.calls = [] - async def __call__(self, plugin_name, action, payload, next_call, handler, **kwargs): + async def __call__( + self, plugin_name, action, payload, next_call, handler, **kwargs + ): self.calls.append((plugin_name, action, payload)) return await next_call(plugin_name, action, payload, handler, **kwargs) @@ -41,7 +41,9 @@ async def test_compiled_pipeline_handler_propagation(): received_handlers = [] class HandlerTrackingMiddleware(Middleware): - async def __call__(self, plugin_name, action, payload, next_call, handler, **kwargs): + async def __call__( + self, plugin_name, action, payload, next_call, handler, **kwargs + ): received_handlers.append(handler) return await next_call(plugin_name, action, payload, handler, **kwargs) diff --git a/tests/unit/kernel/test_permissions.py b/tests/unit/kernel/test_permissions.py index f3970262..2048c549 100644 --- a/tests/unit/kernel/test_permissions.py +++ b/tests/unit/kernel/test_permissions.py @@ -10,8 +10,7 @@ def engine(self): return PermissionEngine() def test_load_and_check(self, engine): - permissions = [{"resource": "db.*", - "actions": ["read"], "effect": "allow"}] + permissions = [{"resource": "db.*", "actions": ["read"], "effect": "allow"}] engine.load_from_manifest("test_plugin", permissions) # Should allow @@ -24,8 +23,7 @@ def test_load_and_check(self, engine): assert engine.allows("test_plugin", "db.users", "write") is False def test_memoization(self, engine): - permissions = [{"resource": "db.*", - "actions": ["read"], "effect": "allow"}] + permissions = [{"resource": "db.*", "actions": ["read"], "effect": "allow"}] engine.load_from_manifest("test_plugin", permissions) # First call, populates cache @@ -45,8 +43,7 @@ def test_memoization(self, engine): assert engine.allows("test_plugin", "db.users", "read") is False def test_cache_invalidation_load(self, engine): - permissions = [{"resource": "db.*", - "actions": ["read"], "effect": "allow"}] + permissions = [{"resource": "db.*", "actions": ["read"], "effect": "allow"}] engine.load_from_manifest("test_plugin", permissions) engine.allows("test_plugin", "db.users", "read") assert len(engine._cache) > 0 @@ -56,8 +53,7 @@ def test_cache_invalidation_load(self, engine): def test_cache_invalidation_grant_all(self, engine): engine.load_from_manifest( - "test_plugin", [{"resource": "db.*", - "actions": ["read"], "effect": "deny"}] + "test_plugin", [{"resource": "db.*", "actions": ["read"], "effect": "deny"}] ) engine.allows("test_plugin", "db.users", "read") assert len(engine._cache) > 0 diff --git a/tests/unit/kernel/test_policies.py b/tests/unit/kernel/test_policies.py index d946e8d2..733c0cbc 100644 --- a/tests/unit/kernel/test_policies.py +++ b/tests/unit/kernel/test_policies.py @@ -66,8 +66,7 @@ def test_matches_combined_wildcards(self): def test_from_dict_basic(self): """Test from_dict with basic data.""" - data = {"resource": "db.*", - "actions": ["read", "write"], "effect": "allow"} + data = {"resource": "db.*", "actions": ["read", "write"], "effect": "allow"} policy = Policy.from_dict(data) assert policy.resource == "db.*" assert policy.actions == ["read", "write"] @@ -101,12 +100,9 @@ class TestPolicySet: def sample_policies(self): """Create sample policies.""" return [ - Policy(resource="db.*", - actions=["read"], effect=PolicyEffect.ALLOW), - Policy(resource="db.*", - actions=["write"], effect=PolicyEffect.DENY), - Policy(resource="cache.*", - actions=["*"], effect=PolicyEffect.ALLOW), + Policy(resource="db.*", actions=["read"], effect=PolicyEffect.ALLOW), + Policy(resource="db.*", actions=["write"], effect=PolicyEffect.DENY), + Policy(resource="cache.*", actions=["*"], effect=PolicyEffect.ALLOW), ] def test_creation(self, sample_policies): @@ -178,8 +174,7 @@ def test_deny_all(self): def test_to_list(self): """Test to_list serialization.""" policies = [ - Policy(resource="db.*", - actions=["read"], effect=PolicyEffect.ALLOW), + Policy(resource="db.*", actions=["read"], effect=PolicyEffect.ALLOW), ] ps = PolicySet(plugin_name="test", policies=policies) result = ps.to_list() @@ -210,8 +205,7 @@ def test_order_matters(self): """Test that policy order affects evaluation.""" # More specific policy first policies_ordered = [ - Policy(resource="db.users", actions=[ - "read"], effect=PolicyEffect.ALLOW), + Policy(resource="db.users", actions=["read"], effect=PolicyEffect.ALLOW), Policy(resource="db.*", actions=["*"], effect=PolicyEffect.DENY), ] ps = PolicySet(plugin_name="test", policies=policies_ordered) @@ -221,8 +215,7 @@ def test_order_matters(self): def test_partial_wildcard_match(self): """Test partial wildcard in resource.""" policies = [ - Policy(resource="db.*.read", - actions=["*"], effect=PolicyEffect.ALLOW), + Policy(resource="db.*.read", actions=["*"], effect=PolicyEffect.ALLOW), ] ps = PolicySet(plugin_name="test", policies=policies) assert ps.allows("db.users.read", "anything") is True @@ -231,8 +224,7 @@ def test_partial_wildcard_match(self): def test_complex_wildcard_pattern(self): """Test complex wildcard patterns.""" policies = [ - Policy(resource="db.user*.*", - actions=["read"], effect=PolicyEffect.ALLOW), + Policy(resource="db.user*.*", actions=["read"], effect=PolicyEffect.ALLOW), ] ps = PolicySet(plugin_name="test", policies=policies) assert ps.allows("db.user1.profile", "read") is True diff --git a/tests/unit/kernel/test_ratelimit.py b/tests/unit/kernel/test_ratelimit.py index b1f7d27e..d711ba56 100644 --- a/tests/unit/kernel/test_ratelimit.py +++ b/tests/unit/kernel/test_ratelimit.py @@ -2,9 +2,12 @@ import pytest -from xcore.kernel.sandbox.limits import (RateLimitConfig, RateLimiter, - RateLimiterRegistry, - RateLimitExceeded) +from xcore.kernel.sandbox.limits import ( + RateLimitConfig, + RateLimiter, + RateLimiterRegistry, + RateLimitExceeded, +) def test_ratelimiter_basic(): diff --git a/tests/unit/kernel/test_registry_protection_new.py b/tests/unit/kernel/test_registry_protection_new.py index 151d0dfb..899a5e22 100644 --- a/tests/unit/kernel/test_registry_protection_new.py +++ b/tests/unit/kernel/test_registry_protection_new.py @@ -14,8 +14,7 @@ def test_protected_service_protection(): # 3. Attempt to overwrite from a plugin with pytest.raises(PermissionError) as excinfo: - registry.register_service( - "malicious_plugin", "db", {"info": "fake_db"}) + registry.register_service("malicious_plugin", "db", {"info": "fake_db"}) assert "Impossible d'écraser le service protégé 'db'" in str(excinfo.value) diff --git a/tests/unit/kernel/test_sandbox_worker.py b/tests/unit/kernel/test_sandbox_worker.py index 2f32f6fc..b20a27d3 100644 --- a/tests/unit/kernel/test_sandbox_worker.py +++ b/tests/unit/kernel/test_sandbox_worker.py @@ -11,9 +11,13 @@ import pytest # Test the components we can import -from xcore.kernel.sandbox.worker import (FilesystemGuard, _apply_memory_limit, - _load_manifest, _PluginImportHook, - _PluginManifest) +from xcore.kernel.sandbox.worker import ( + FilesystemGuard, + _apply_resource_limits, + _load_manifest, + _PluginImportHook, + _PluginManifest, +) class TestPluginManifest: @@ -38,34 +42,40 @@ def test_custom_values(self): assert manifest.denied_paths == ["system/", "logs/"] -class TestApplyMemoryLimit: - """Test _apply_memory_limit function.""" +class TestApplyResourceLimits: + """Test _apply_resource_limits function.""" def test_no_limit_set(self): """Test when no memory limit is set.""" - with patch.dict(os.environ, {"_SANDBOX_MAX_MEM_MB": "0"}, clear=True): + with patch.dict( + os.environ, + {"_SANDBOX_MAX_MEM_MB": "0", "_SANDBOX_MAX_CPU_SEC": "0"}, + clear=True, + ): # Should not raise - _apply_memory_limit() + _apply_resource_limits() def test_limit_on_windows(self): - """Test memory limit on Windows (should skip).""" - with patch.dict(os.environ, {"_SANDBOX_MAX_MEM_MB": "100"}): + """Test resource limits on Windows (should skip).""" + with patch.dict( + os.environ, {"_SANDBOX_MAX_MEM_MB": "100", "_SANDBOX_MAX_CPU_SEC": "10"} + ): with patch.object(sys, "platform", "win32"): # Should not raise (Windows is skipped) - _apply_memory_limit() + _apply_resource_limits() @pytest.mark.skipif(sys.platform == "win32", reason="Not applicable on Windows") - def test_apply_limit_unix(self): - """Test applying memory limit on Unix.""" - with patch.dict(os.environ, {"_SANDBOX_MAX_MEM_MB": "100"}): + def test_apply_limits_unix(self): + """Test applying resource limits on Unix.""" + env = { + "_SANDBOX_MAX_MEM_MB": "100", + "_SANDBOX_MAX_CPU_SEC": "10", + } + with patch.dict(os.environ, env): with patch("resource.setrlimit") as mock_setrlimit: - with patch("resource.RLIMIT_AS", 9): # Mock RLIMIT_AS - _apply_memory_limit() - mock_setrlimit.assert_called_once() - # Check the limit is 100MB in bytes - call_args = mock_setrlimit.call_args - assert call_args[0][1] == ( - 100 * 1024 * 1024, 100 * 1024 * 1024) + _apply_resource_limits() + # On attend au moins un appel pour CPU et un pour mémoire + assert mock_setrlimit.call_count >= 2 class TestFilesystemGuard: @@ -288,8 +298,7 @@ def guard(self, tmp_path): def test_empty_allowed_paths(self, tmp_path): """Test guard with empty allowed paths.""" - guard = FilesystemGuard(plugin_dir=tmp_path, - allowed_paths=[], denied_paths=[]) + guard = FilesystemGuard(plugin_dir=tmp_path, allowed_paths=[], denied_paths=[]) # With no allowed paths, everything should be denied (fail-closed) assert guard.is_allowed(tmp_path / "any/file.txt") is False diff --git a/tests/unit/kernel/test_state_machine.py b/tests/unit/kernel/test_state_machine.py index bd7fe7e5..a64e9542 100644 --- a/tests/unit/kernel/test_state_machine.py +++ b/tests/unit/kernel/test_state_machine.py @@ -4,8 +4,11 @@ import pytest -from xcore.kernel.runtime.state_machine import (InvalidTransition, PluginState, - StateMachine) +from xcore.kernel.runtime.state_machine import ( + InvalidTransition, + PluginState, + StateMachine, +) class TestPluginState: diff --git a/tests/unit/kernel/test_supervisor_new.py b/tests/unit/kernel/test_supervisor_new.py index d8bef081..12cd7b02 100644 --- a/tests/unit/kernel/test_supervisor_new.py +++ b/tests/unit/kernel/test_supervisor_new.py @@ -23,6 +23,7 @@ def mock_config(): @pytest.fixture def supervisor(mock_config): from xcore.kernel.context import KernelContext + services = MagicMock() services.as_dict.return_value = {} ctx = KernelContext( diff --git a/tests/unit/plugins/test_base.py b/tests/unit/plugins/test_base.py index 4c610cd3..df865294 100644 --- a/tests/unit/plugins/test_base.py +++ b/tests/unit/plugins/test_base.py @@ -5,8 +5,12 @@ import pytest from xcore.kernel.api.contract import ExecutionMode -from xcore.sdk.plugin_base import (PluginManifest, RateLimitConfig, - ResourceConfig, RuntimeConfig) +from xcore.sdk.plugin_base import ( + PluginManifest, + RateLimitConfig, + ResourceConfig, + RuntimeConfig, +) class TestRateLimitConfig: diff --git a/tests/unit/plugins/test_decorators.py b/tests/unit/plugins/test_decorators.py index a8171709..ca5b352c 100644 --- a/tests/unit/plugins/test_decorators.py +++ b/tests/unit/plugins/test_decorators.py @@ -7,9 +7,16 @@ import pytest from pydantic import BaseModel, ValidationError -from xcore.sdk.decorators import (AutoDispatchMixin, RoutedPlugin, action, - require_service, route, sandboxed, trusted, - validate_payload) +from xcore.sdk.decorators import ( + AutoDispatchMixin, + RoutedPlugin, + action, + require_service, + route, + sandboxed, + trusted, + validate_payload, +) class TestActionDecorator: @@ -169,8 +176,7 @@ async def my_action(self, payload: dict) -> dict: plugin = Plugin() assert hasattr(plugin.my_action, "_requires_services") - assert plugin.my_action._requires_services == [ - "db", "cache", "scheduler"] + assert plugin.my_action._requires_services == ["db", "cache", "scheduler"] class TestValidatePayloadDecorator: diff --git a/tests/unit/security/test_ast_bypass.py b/tests/unit/security/test_ast_bypass.py index 15830ccd..45f94f54 100644 --- a/tests/unit/security/test_ast_bypass.py +++ b/tests/unit/security/test_ast_bypass.py @@ -3,9 +3,11 @@ import pytest -from xcore.kernel.security.validation import (DEFAULT_ALLOWED, - DEFAULT_FORBIDDEN, - _SecurityVisitor) +from xcore.kernel.security.validation import ( + DEFAULT_ALLOWED, + DEFAULT_FORBIDDEN, + _SecurityVisitor, +) class TestASTBypass: @@ -25,10 +27,7 @@ def test_pathlib_os_bypass(self, visitor): visitor.visit(tree) # Should have an error for 'os' attribute - assert any( - "accès à un module interdit via attribut : 'os'" in e - for e in visitor.errors - ) + assert any("accès interdit via attribut : 'os'" in e for e in visitor.errors) def test_importlib_sys_bypass(self, visitor): """Test that importlib.sys is blocked.""" @@ -37,10 +36,7 @@ def test_importlib_sys_bypass(self, visitor): visitor.visit(tree) # Should have an error for 'sys' attribute - assert any( - "accès à un module interdit via attribut : 'sys'" in e - for e in visitor.errors - ) + assert any("accès interdit via attribut : 'sys'" in e for e in visitor.errors) def test_nested_bypass(self, visitor): """Test that nested attribute access is blocked.""" @@ -48,10 +44,7 @@ def test_nested_bypass(self, visitor): tree = ast.parse(code) visitor.visit(tree) - assert any( - "accès à un module interdit via attribut : 'os'" in e - for e in visitor.errors - ) + assert any("accès interdit via attribut : 'os'" in e for e in visitor.errors) def test_legitimate_attribute_access(self, visitor): """Test that legitimate attribute access is NOT blocked.""" diff --git a/tests/unit/security/test_ast_scanner.py b/tests/unit/security/test_ast_scanner.py index e80b9a55..3b278338 100644 --- a/tests/unit/security/test_ast_scanner.py +++ b/tests/unit/security/test_ast_scanner.py @@ -8,11 +8,16 @@ import pytest -from xcore.kernel.security.validation import (DEFAULT_ALLOWED, - DEFAULT_FORBIDDEN, ASTScanner, - ManifestError, ManifestValidator, - ScanResult, _resolve_env, - _SecurityVisitor) +from xcore.kernel.security.validation import ( + DEFAULT_ALLOWED, + DEFAULT_FORBIDDEN, + ASTScanner, + ManifestError, + ManifestValidator, + ScanResult, + _resolve_env, + _SecurityVisitor, +) class TestScanResult: @@ -49,8 +54,7 @@ def test_str_passed(self): def test_str_failed(self): """Test __str__ when failed.""" - result = ScanResult(passed=False, errors=[ - "Error 1"], warnings=["Warning 1"]) + result = ScanResult(passed=False, errors=["Error 1"], warnings=["Warning 1"]) result_str = str(result) assert "❌" in result_str assert "Error 1" in result_str @@ -372,16 +376,14 @@ def test_inject_dotenv_disabled(self, validator, tmp_path): def test_inject_dotenv_missing_file(self, validator, tmp_path): """Test _inject_dotenv with missing file.""" with pytest.raises(ManifestError) as exc_info: - validator._inject_dotenv( - {"inject": True, "env_file": ".env"}, tmp_path) + validator._inject_dotenv({"inject": True, "env_file": ".env"}, tmp_path) assert "introuvable" in str(exc_info.value) def test_inject_dotenv_path_traversal(self, validator, tmp_path): """Test _inject_dotenv blocks path traversal.""" with pytest.raises(ManifestError) as exc_info: - validator._inject_dotenv( - {"inject": True, "env_file": "../.env"}, tmp_path) + validator._inject_dotenv({"inject": True, "env_file": "../.env"}, tmp_path) assert "traversal" in str(exc_info.value) diff --git a/tests/unit/security/test_sentinel_fixes.py b/tests/unit/security/test_sentinel_fixes.py index 79a3178b..4f3f9873 100644 --- a/tests/unit/security/test_sentinel_fixes.py +++ b/tests/unit/security/test_sentinel_fixes.py @@ -1,6 +1,3 @@ -import ast -from pathlib import Path - import pytest from xcore.kernel.security.validation import ASTScanner @@ -24,10 +21,10 @@ def leak_info(): assert not result.passed error_msgs = "\n".join(result.errors) - assert "utilisation de built-in interdit : 'dir'" in error_msgs - assert "utilisation de built-in interdit : 'vars'" in error_msgs - assert "utilisation de built-in interdit : 'input'" in error_msgs - assert "utilisation de built-in interdit : 'help'" in error_msgs + assert "built-in interdit : 'dir'" in error_msgs + assert "built-in interdit : 'vars'" in error_msgs + assert "built-in interdit : 'input'" in error_msgs + assert "built-in interdit : 'help'" in error_msgs def test_ast_scanner_blocks_new_forbidden_modules(tmp_path): @@ -50,10 +47,10 @@ def test_ast_scanner_blocks_new_forbidden_modules(tmp_path): assert not result.passed error_msgs = "\n".join(result.errors) - assert "import interdit : 'multiprocessing'" in error_msgs - assert "import interdit : 'threading'" in error_msgs - assert "import interdit : 'concurrent.futures'" in error_msgs - assert "import interdit : 'pty'" in error_msgs - assert "import interdit : 'termios'" in error_msgs - assert "import interdit : 'tty'" in error_msgs - assert "import interdit : 'fcntl'" in error_msgs + assert "import interdit — 'multiprocessing'" in error_msgs + assert "import interdit — 'threading'" in error_msgs + assert "import interdit — 'concurrent.futures'" in error_msgs + assert "import interdit — 'pty'" in error_msgs + assert "import interdit — 'termios'" in error_msgs + assert "import interdit — 'tty'" in error_msgs + assert "import interdit — 'fcntl'" in error_msgs diff --git a/tests/unit/security/test_signature_bypass.py b/tests/unit/security/test_signature_bypass.py index f11f6eeb..43ed6c7c 100644 --- a/tests/unit/security/test_signature_bypass.py +++ b/tests/unit/security/test_signature_bypass.py @@ -1,12 +1,9 @@ - -import json from pathlib import Path import pytest import yaml -from xcore.kernel.security.signature import (SignatureError, sign_plugin, - verify_plugin) +from xcore.kernel.security.signature import SignatureError, sign_plugin, verify_plugin class MockManifest: @@ -38,12 +35,11 @@ def test_signature_bypass_outside_src(tmp_path): manifest_data = { "name": "my_plugin", "version": "1.0.0", - "entry_point": "app/main.py" + "entry_point": "app/main.py", } (plugin_dir / "plugin.yaml").write_text(yaml.dump(manifest_data)) - manifest = MockManifest("my_plugin", "1.0.0", - plugin_dir, entry_point="app/main.py") + manifest = MockManifest("my_plugin", "1.0.0", plugin_dir, entry_point="app/main.py") secret_key = b"secret" # Sign the plugin @@ -78,8 +74,7 @@ def test_signature_root_entry_point(tmp_path): root_main = plugin_dir / "main.py" root_main.write_text("print('Safe root code')") - manifest = MockManifest("root_plugin", "1.0.0", - plugin_dir, entry_point="main.py") + manifest = MockManifest("root_plugin", "1.0.0", plugin_dir, entry_point="main.py") secret_key = b"secret" # Sign @@ -92,6 +87,7 @@ def test_signature_root_entry_point(tmp_path): try: verify_plugin(manifest, secret_key) pytest.fail( - "Signature verification should have failed after modifying code at root!") + "Signature verification should have failed after modifying code at root!" + ) except SignatureError: pass diff --git a/tests/unit/security/test_validation.py b/tests/unit/security/test_validation.py index a88b3785..5ac0bdd4 100644 --- a/tests/unit/security/test_validation.py +++ b/tests/unit/security/test_validation.py @@ -8,8 +8,12 @@ import pytest from xcore.kernel.api.contract import ExecutionMode -from xcore.kernel.security.validation import (ASTScanner, ManifestError, - ManifestValidator, ScanResult) +from xcore.kernel.security.validation import ( + ASTScanner, + ManifestError, + ManifestValidator, + ScanResult, +) class TestManifestValidator: @@ -36,7 +40,7 @@ def test_load_valid_yaml_manifest(self, validator, temp_plugin_dir): """ (temp_plugin_dir / "plugin.yaml").write_text(manifest_content) - manifest = validator.load_and_validate(temp_plugin_dir) + manifest, _, _ = validator.load_and_validate(temp_plugin_dir) assert manifest.name == "test_plugin" assert manifest.version == "1.0.0" @@ -52,7 +56,7 @@ def test_load_valid_json_manifest(self, validator, temp_plugin_dir): }""" (temp_plugin_dir / "plugin.json").write_text(manifest_content) - manifest = validator.load_and_validate(temp_plugin_dir) + manifest, _, _ = validator.load_and_validate(temp_plugin_dir) assert manifest.name == "json_plugin" assert manifest.version == "2.0.0" diff --git a/tests/unit/services/test_container.py b/tests/unit/services/test_container.py index f35aa019..4be0dadc 100644 --- a/tests/unit/services/test_container.py +++ b/tests/unit/services/test_container.py @@ -10,11 +10,13 @@ import pytest from xcore.services.base import BaseService, BaseServiceProvider, ServiceStatus -from xcore.services.container import (CacheServiceProvider, - DatabaseServiceProvider, - ExtensionServiceProvider, - SchedulerServiceProvider, - ServiceContainer) +from xcore.services.container import ( + CacheServiceProvider, + DatabaseServiceProvider, + ExtensionServiceProvider, + SchedulerServiceProvider, + ServiceContainer, +) # Mock service classes for testing @@ -75,22 +77,10 @@ def container(self, mock_config): def test_default_providers_present(self): """Test default providers are present.""" assert len(ServiceContainer.DEFAULT_PROVIDERS) == 4 - assert any( - isinstance(p, DatabaseServiceProvider) - for p in ServiceContainer.DEFAULT_PROVIDERS - ) - assert any( - isinstance(p, CacheServiceProvider) - for p in ServiceContainer.DEFAULT_PROVIDERS - ) - assert any( - isinstance(p, SchedulerServiceProvider) - for p in ServiceContainer.DEFAULT_PROVIDERS - ) - assert any( - isinstance(p, ExtensionServiceProvider) - for p in ServiceContainer.DEFAULT_PROVIDERS - ) + assert DatabaseServiceProvider in ServiceContainer.DEFAULT_PROVIDERS + assert CacheServiceProvider in ServiceContainer.DEFAULT_PROVIDERS + assert SchedulerServiceProvider in ServiceContainer.DEFAULT_PROVIDERS + assert ExtensionServiceProvider in ServiceContainer.DEFAULT_PROVIDERS def test_get_nonexistent_service(self, container): """Test getting nonexistent service raises KeyError.""" @@ -223,8 +213,7 @@ async def test_health_with_services(self, container): async def test_health_with_failing_service(self, container): """Test health check with failing service.""" service = MockService() - service.health_check = AsyncMock( - return_value=(False, "Connection failed")) + service.health_check = AsyncMock(return_value=(False, "Connection failed")) container._services["test"] = service result = await container.health() @@ -236,8 +225,7 @@ async def test_health_with_failing_service(self, container): async def test_health_exception(self, container): """Test health check when service raises exception.""" service = MockService() - service.health_check = AsyncMock( - side_effect=Exception("Health check failed")) + service.health_check = AsyncMock(side_effect=Exception("Health check failed")) container._services["test"] = service result = await container.health() diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py index d6178ec2..3a61e582 100644 --- a/tests/unit/test_configuration.py +++ b/tests/unit/test_configuration.py @@ -8,8 +8,7 @@ import yaml from xcore.configurations.loader import ConfigLoader -from xcore.configurations.sections import (AppConfig, PluginConfig, - ServicesConfig) +from xcore.configurations.sections import AppConfig, PluginConfig, ServicesConfig class TestConfigLoader: @@ -61,22 +60,22 @@ def test_load_with_env_substitution(self, temp_dir): config = ConfigLoader.load(str(config_path)) - assert config.app.secret_key == "from_environment" + assert config.app.secret_key == b"from_environment" del os.environ["TEST_SECRET"] def test_load_missing_file(self): - """Test loading non-existent file.""" - with pytest.raises(FileNotFoundError): - ConfigLoader.load("/nonexistent/path.yaml") + """Test loading non-existent file returns default config.""" + config = ConfigLoader.load("/nonexistent/path.yaml") + assert config.app.name == "xcore-app" def test_load_invalid_yaml(self, temp_dir): - """Test loading invalid YAML.""" + """Test loading invalid YAML returns default config.""" config_path = temp_dir / "test.yaml" config_path.write_text("invalid: yaml: : : content") - with pytest.raises(Exception): - ConfigLoader.load(str(config_path)) + config = ConfigLoader.load(str(config_path)) + assert config.app.name == "xcore-app" class TestAppConfig: @@ -114,7 +113,7 @@ def test_default_values(self): config = PluginConfig(directory="./plugins", secret_key=None) assert config.strict_trusted is False - assert config.interval == 0 + assert config.interval == 2 assert config.entry_point == "src/main.py" def test_custom_values(self): diff --git a/tests/unit/test_registry_new.py b/tests/unit/test_registry_new.py index 9fe3b6d1..50df949f 100644 --- a/tests/unit/test_registry_new.py +++ b/tests/unit/test_registry_new.py @@ -42,8 +42,7 @@ def test_registry_service_scoping(): ) # Access by owner should work - assert registry.get_service("private_svc", requester="plugin1") == { - "secret": 123} + assert registry.get_service("private_svc", requester="plugin1") == {"secret": 123} # Access by others should fail with pytest.raises(PermissionError): diff --git a/xcore/__init__.py b/xcore/__init__.py index 84a84c13..bc3b54c6 100644 --- a/xcore/__init__.py +++ b/xcore/__init__.py @@ -18,11 +18,24 @@ """ from .__version__ import __version__ +from .kernel.api import ( + AuthBackend, + AuthPayload, + get_auth_backend, + get_current_user, + register_auth_backend, + unregister_auth_backend, +) from .kernel.api.contract import BasePlugin, TrustedBase from .kernel.events.bus import EventBus from .kernel.events.hooks import HookManager -from .kernel.observability import (HealthChecker, MetricsRegistry, Tracer, - configure_logging, get_logger) +from .kernel.observability import ( + HealthChecker, + MetricsRegistry, + Tracer, + configure_logging, + get_logger, +) from .kernel.runtime.lifecycle import LifecycleManager from .kernel.runtime.loader import PluginLoader from .kernel.runtime.supervisor import PluginSupervisor @@ -45,6 +58,12 @@ "ServiceContainer", "PluginRegistry", "get_logger", + "AuthBackend", + "AuthPayload", + "get_auth_backend", + "get_current_user", + "register_auth_backend", + "unregister_auth_backend", ] @@ -86,11 +105,13 @@ def __init__(self, config_path: str | None = None): self._config = ConfigLoader.load(config_path) self._booted = False - # Sous-systèmes — instanciés lazily à boot() + # Sous-systèmes — instanciés dans __init__ pour les décorateurs + self.events = EventBus() + self.hooks = HookManager() + + # Sous-systèmes instanciés lazily à boot() self.services: ServiceContainer | None = None self.plugins: PluginSupervisor | None = None - self.events: EventBus | None = None - self.hooks: HookManager | None = None self.registry: PluginRegistry | None = None self._logger = get_logger("xcore") @@ -121,11 +142,7 @@ async def boot(self, app=None) -> "Xcore": self.services.load_default_providers() await self.services.init() - # 2. Event bus + hooks - self.events = EventBus() - self.hooks = HookManager() - - # 3. Registry des plugins + # 2. Registry des plugins self.registry = PluginRegistry(self._config) # 4. Kernel Context & Plugin supervisor (runtime + sandbox) diff --git a/xcore/cli/main.py b/xcore/cli/main.py index 933c742b..89007cdd 100644 --- a/xcore/cli/main.py +++ b/xcore/cli/main.py @@ -40,8 +40,7 @@ def main() -> None: prog="xcore", description="xcore v2 — gestion du framework plugin-first", ) - parser.add_argument("--config", default=None, - help="Chemin vers xcore.yaml") + parser.add_argument("--config", default=None, help="Chemin vers xcore.yaml") parser.add_argument("--version", action="store_true") subparsers = parser.add_subparsers(dest="command") @@ -99,22 +98,19 @@ def main() -> None: default="marketplace", help="Source d'installation (défaut: marketplace)", ) - install_p.add_argument("--url", default=None, - help="URL directe (zip ou git)") + install_p.add_argument("--url", default=None, help="URL directe (zip ou git)") remove_p = plugin_sub.add_parser("remove", help="Supprime un plugin") remove_p.add_argument("name") - info_p = plugin_sub.add_parser( - "info", help="Affiche les métadonnées d'un plugin") + info_p = plugin_sub.add_parser("info", help="Affiche les métadonnées d'un plugin") info_p.add_argument("name") sign_p = plugin_sub.add_parser("sign", help="Signe un plugin Trusted") sign_p.add_argument("path") sign_p.add_argument("--key", default=None) - verify_p = plugin_sub.add_parser( - "verify", help="Vérifie la signature d'un plugin") + verify_p = plugin_sub.add_parser("verify", help="Vérifie la signature d'un plugin") verify_p.add_argument("path") verify_p.add_argument("--key", default=None) @@ -124,12 +120,10 @@ def main() -> None: validate_p.add_argument("path") # ── sandbox ─────────────────────────────────────────────── - sandbox_p = subparsers.add_parser( - "sandbox", help="Gestion du sandbox runtime") + sandbox_p = subparsers.add_parser("sandbox", help="Gestion du sandbox runtime") sandbox_sub = sandbox_p.add_subparsers(dest="subcommand") - sb_run = sandbox_sub.add_parser( - "run", help="Lance un plugin en mode sandbox isolé") + sb_run = sandbox_sub.add_parser("run", help="Lance un plugin en mode sandbox isolé") sb_run.add_argument("name", help="Nom du plugin") sb_limits = sandbox_sub.add_parser( @@ -162,15 +156,13 @@ def main() -> None: mkt_rate = mkt_sub.add_parser("rate", help="Note un plugin") mkt_rate.add_argument("name") - mkt_rate.add_argument("--score", type=int, - choices=range(1, 6), required=True) + mkt_rate.add_argument("--score", type=int, choices=range(1, 6), required=True) # ── services ────────────────────────────────────────────── svc_p = subparsers.add_parser("services", help="État des services") svc_sub = svc_p.add_subparsers(dest="subcommand") svc_status_p = svc_sub.add_parser("status") - svc_status_p.add_argument( - "--json", action="store_true", help="Output as JSON") + svc_status_p.add_argument("--json", action="store_true", help="Output as JSON") health_p = subparsers.add_parser("health", help="Health check global") health_p.add_argument("--json", action="store_true", help="Output as JSON") diff --git a/xcore/cli/marketplace_cmd.py b/xcore/cli/marketplace_cmd.py index 82e55f4f..6e1d4c38 100644 --- a/xcore/cli/marketplace_cmd.py +++ b/xcore/cli/marketplace_cmd.py @@ -73,10 +73,8 @@ async def _mkt_list(args) -> None: table.add_column( "Nom", style="cyan", no_wrap=True, max_width=32, overflow="ellipsis" ) - table.add_column("Version", style="magenta", - max_width=16, overflow="ellipsis") - table.add_column("Auteur", style="green", - max_width=24, overflow="ellipsis") + table.add_column("Version", style="magenta", max_width=16, overflow="ellipsis") + table.add_column("Auteur", style="green", max_width=24, overflow="ellipsis") table.add_column("Note") table.add_column("Description", style="white") @@ -188,8 +186,7 @@ async def _mkt_show(args) -> None: sys.exit(1) if not plugin: - error_console.print( - f"[bold red]❌ Plugin '{escape(name)}' introuvable.[/]") + error_console.print(f"[bold red]❌ Plugin '{escape(name)}' introuvable.[/]") sys.exit(1) info = [ @@ -215,8 +212,7 @@ async def _mkt_show(args) -> None: content += f"\n[italic grey70]Pour installer :[/]\n [bold]xcore plugin install {escape(name)}[/]" title = f"[bold green]📦 {escape(plugin.get('name', name))} v{escape(str(plugin.get('version', '?')))}[/]" - console.print(Panel(content, title=title, - expand=False, border_style="cyan")) + console.print(Panel(content, title=title, expand=False, border_style="cyan")) # ── rate ────────────────────────────────────────────────────── diff --git a/xcore/cli/plugin_cmd.py b/xcore/cli/plugin_cmd.py index 0d3e0f81..2e4f5fd3 100644 --- a/xcore/cli/plugin_cmd.py +++ b/xcore/cli/plugin_cmd.py @@ -78,8 +78,7 @@ async def _plugin_list(args) -> None: cfg = _load_config(args) plugin_dir = Path(cfg.plugins.directory) if not plugin_dir.exists(): - console.print( - f"[bold red]❌ Dossier plugins introuvable :[/] {plugin_dir}") + console.print(f"[bold red]❌ Dossier plugins introuvable :[/] {plugin_dir}") return plugins = sorted( d.name @@ -109,8 +108,7 @@ async def _plugin_list(args) -> None: desc = m.get("description", "") table.add_row(p, f"v{version}", mode, desc) except Exception: - table.add_row(p, "[red]?[/]", "[red]?[/]", - "[red]Erreur de lecture[/]") + table.add_row(p, "[red]?[/]", "[red]?[/]", "[red]Erreur de lecture[/]") else: table.add_row( p, @@ -129,8 +127,7 @@ async def _plugin_health(args) -> None: cfg = _load_config(args) plugin_dir = Path(cfg.plugins.directory) if not plugin_dir.exists(): - console.print( - f"[bold red]❌ Dossier plugins introuvable :[/] {plugin_dir}") + console.print(f"[bold red]❌ Dossier plugins introuvable :[/] {plugin_dir}") return plugins = sorted( @@ -223,8 +220,7 @@ async def _plugin_install(args) -> None: elif source == "zip" or (url and (url.endswith(".zip") or url.startswith("http"))): if not url: - console.print( - f"[bold red]❌ Erreur :[/] --url requis pour --source zip") + console.print(f"[bold red]❌ Erreur :[/] --url requis pour --source zip") sys.exit(1) await _install_from_zip(name, url, dest) @@ -256,8 +252,7 @@ async def _install_from_git(name: str, url: str, dest: Path) -> None: ) _, stderr = await proc.communicate() if proc.returncode != 0: - console.print( - f"[bold red]❌ git clone échoué :[/] {stderr.decode().strip()}") + console.print(f"[bold red]❌ git clone échoué :[/] {stderr.decode().strip()}") sys.exit(1) @@ -277,15 +272,14 @@ async def _install_from_zip(name: str, url: str, dest: Path) -> None: with zipfile.ZipFile(io.BytesIO(data)) as zf: # Détecte un sous-dossier racine dans le zip members = zf.namelist() - prefix = members[0].split( - "/")[0] + "/" if "/" in members[0] else "" + prefix = members[0].split("/")[0] + "/" if "/" in members[0] else "" dest_resolved = dest.resolve() dest_resolved.mkdir(parents=True, exist_ok=True) for member in members: stripped = ( - member[len(prefix):] + member[len(prefix) :] if prefix and member.startswith(prefix) else member ) @@ -378,8 +372,7 @@ async def _plugin_remove(args) -> None: ) sys.exit(1) - confirm = Confirm.ask( - f"[bold red]⚠️ Supprimer '{name}' ?[/]", default=False) + confirm = Confirm.ask(f"[bold red]⚠️ Supprimer '{name}' ?[/]", default=False) if not confirm: console.print("Annulé.") return @@ -443,8 +436,7 @@ async def _plugin_info(args) -> None: # Permissions if manifest.permissions: - info.append( - f"\n[bold white]Permissions ({len(manifest.permissions)}) :[/]") + info.append(f"\n[bold white]Permissions ({len(manifest.permissions)}) :[/]") for p in manifest.permissions: effect = p.get("effect", "allow") symbol = "✅" if effect == "allow" else "❌" @@ -454,8 +446,7 @@ async def _plugin_info(args) -> None: content = Group(*info) title = f"[bold green]🔌 {escape(manifest.name)} v{escape(manifest.version)}[/]" - console.print(Panel(content, title=title, - expand=False, border_style="cyan")) + console.print(Panel(content, title=title, expand=False, border_style="cyan")) # ── sign / verify / validate ────────────────────────────────── @@ -642,8 +633,7 @@ async def handle_services(args) -> None: color = "orange1" # On retire les clés déjà affichées dans les colonnes - details = {k: v for k, v in info.items( - ) if k not in ("name", "status")} + details = {k: v for k, v in info.items() if k not in ("name", "status")} details_str = ", ".join(f"{k}={v}" for k, v in details.items()) table.add_row( @@ -655,8 +645,7 @@ async def handle_services(args) -> None: console.print(table) if status.get("registered_keys"): keys = ", ".join(status["registered_keys"]) - console.print( - f"\n[bold cyan]Clés enregistrées :[/] [dim]{escape(keys)}[/]") + console.print(f"\n[bold cyan]Clés enregistrées :[/] [dim]{escape(keys)}[/]") async def handle_health(args) -> None: diff --git a/xcore/cli/sandbox_cmd.py b/xcore/cli/sandbox_cmd.py index c1dac642..9e352505 100644 --- a/xcore/cli/sandbox_cmd.py +++ b/xcore/cli/sandbox_cmd.py @@ -62,8 +62,7 @@ async def _sandbox_run(args) -> None: plugin_dir = Path(cfg.plugins.directory) / name if not plugin_dir.is_dir(): - print( - f"❌ Plugin '{name}' introuvable : {plugin_dir}", file=sys.stderr) + print(f"❌ Plugin '{name}' introuvable : {plugin_dir}", file=sys.stderr) sys.exit(1) try: @@ -72,16 +71,17 @@ async def _sandbox_run(args) -> None: print(f"❌ Manifeste invalide : {e}", file=sys.stderr) sys.exit(1) - from xcore.kernel.sandbox.process_manager import (SandboxConfig, - SandboxProcessManager) + from xcore.kernel.sandbox.process_manager import ( + SandboxConfig, + SandboxProcessManager, + ) info = [ f"[bold cyan]Mémoire max :[/][magenta] {manifest.resources.max_memory_mb}MB[/]", f"[bold cyan]Timeout :[/][magenta] {manifest.resources.timeout_seconds}s[/]", ] title = f"[bold green]🚀 Lancement sandbox : {escape(name)}[/]" - console.print(Panel(Group(*info), title=title, - expand=False, border_style="cyan")) + console.print(Panel(Group(*info), title=title, expand=False, border_style="cyan")) config = SandboxConfig( timeout=manifest.resources.timeout_seconds, @@ -146,8 +146,7 @@ async def _sandbox_limits(args) -> None: try: manifest = _load_manifest(plugin_dir) except Exception as e: - error_console.print( - f"[bold red]❌ Manifeste invalide :[/] {escape(str(e))}") + error_console.print(f"[bold red]❌ Manifeste invalide :[/] {escape(str(e))}") sys.exit(1) r = manifest.resources @@ -163,17 +162,14 @@ async def _sandbox_limits(args) -> None: ] if rt.health_check.enabled: - info.append( - f" [dim]intervalle : {rt.health_check.interval_seconds}s[/]") - info.append( - f" [dim]timeout : {rt.health_check.timeout_seconds}s[/]") + info.append(f" [dim]intervalle : {rt.health_check.interval_seconds}s[/]") + info.append(f" [dim]timeout : {rt.health_check.timeout_seconds}s[/]") info.append( f" [cyan]Retry :[/][yellow] {rt.retry.max_attempts} tentative(s)[/]" ) if rt.retry.max_attempts > 1: - info.append( - f" [dim]backoff : {rt.retry.backoff_seconds}s[/]") + info.append(f" [dim]backoff : {rt.retry.backoff_seconds}s[/]") # Vérifie le disque actuel si le plugin est installé data_dir = plugin_dir / "data" @@ -190,8 +186,7 @@ async def _sandbox_limits(args) -> None: content = Group(*info) title = f"[bold green]🛡️ Limites ressources : {escape(name)}[/]" - console.print(Panel(content, title=title, - expand=False, border_style="cyan")) + console.print(Panel(content, title=title, expand=False, border_style="cyan")) # ── network ─────────────────────────────────────────────────── @@ -217,8 +212,7 @@ async def _sandbox_network(args) -> None: try: manifest = _load_manifest(plugin_dir) except Exception as e: - error_console.print( - f"[bold red]❌ Manifeste invalide :[/] {escape(str(e))}") + error_console.print(f"[bold red]❌ Manifeste invalide :[/] {escape(str(e))}") sys.exit(1) info = [] @@ -243,8 +237,7 @@ async def _sandbox_network(args) -> None: ) # Vérifie si des imports réseau sont dans la whitelist du plugin - allowed_network = [ - i for i in manifest.allowed_imports if i in NETWORK_IMPORTS] + allowed_network = [i for i in manifest.allowed_imports if i in NETWORK_IMPORTS] blocked_by_ast = [ e for e in result.errors if any(net in e for net in NETWORK_IMPORTS) ] @@ -265,15 +258,12 @@ async def _sandbox_network(args) -> None: else: info.append("✅ [green]Aucun import réseau détecté dans le code[/]") - info.append( - "\n[dim]Note : isolation réseau OS (namespaces) — Linux uniquement.[/]") - info.append( - "[dim]Pour une isolation complète, utilisez un conteneur Docker.[/]") + info.append("\n[dim]Note : isolation réseau OS (namespaces) — Linux uniquement.[/]") + info.append("[dim]Pour une isolation complète, utilisez un conteneur Docker.[/]") content = Group(*info) title = f"[bold green]🌐 Politique réseau : {escape(name)}[/]" - console.print(Panel(content, title=title, - expand=False, border_style="cyan")) + console.print(Panel(content, title=title, expand=False, border_style="cyan")) # ── fs ──────────────────────────────────────────────────────── @@ -294,8 +284,7 @@ async def _sandbox_fs(args) -> None: try: manifest = _load_manifest(plugin_dir) except Exception as e: - error_console.print( - f"[bold red]❌ Manifeste invalide :[/] {escape(str(e))}") + error_console.print(f"[bold red]❌ Manifeste invalide :[/] {escape(str(e))}") sys.exit(1) fs = manifest.filesystem @@ -312,9 +301,7 @@ async def _sandbox_fs(args) -> None: for p in fs.allowed_paths: abs_path = plugin_dir / p status = ( - "[green]✅ existe[/]" - if abs_path.exists() - else "[yellow]⚠️ manquant[/]" + "[green]✅ existe[/]" if abs_path.exists() else "[yellow]⚠️ manquant[/]" ) table.add_row(escape(str(p)), status) info.append(table) @@ -331,8 +318,7 @@ async def _sandbox_fs(args) -> None: content = Group(*info) title = f"[bold green]📁 Politique filesystem : {escape(name)}[/]" - console.print(Panel(content, title=title, - expand=False, border_style="cyan")) + console.print(Panel(content, title=title, expand=False, border_style="cyan")) # Vérifie si le dossier data/ existe, le créer si nécessaire data_dir = plugin_dir / "data" diff --git a/xcore/cli/validate_cmd.py b/xcore/cli/validate_cmd.py index acc5abeb..2c2bee25 100644 --- a/xcore/cli/validate_cmd.py +++ b/xcore/cli/validate_cmd.py @@ -61,8 +61,7 @@ def validate_full( # 3. Signature (Trusted seulement) if manifest.execution_mode == ExecutionMode.TRUSTED and secret_key: - from xcore.kernel.security.signature import (SignatureError, - verify_plugin) + from xcore.kernel.security.signature import SignatureError, verify_plugin try: verify_plugin(manifest, secret_key) diff --git a/xcore/configurations/__init__.py b/xcore/configurations/__init__.py index 3f0cf715..d2d6246e 100644 --- a/xcore/configurations/__init__.py +++ b/xcore/configurations/__init__.py @@ -1,7 +1,14 @@ from .loader import ConfigLoader, XcoreConfig -from .sections import (AppConfig, CacheConfig, DatabaseConfig, - ObservabilityConfig, PluginConfig, SchedulerConfig, - SecurityConfig, ServicesConfig) +from .sections import ( + AppConfig, + CacheConfig, + DatabaseConfig, + ObservabilityConfig, + PluginConfig, + SchedulerConfig, + SecurityConfig, + ServicesConfig, +) __all__ = [ "ConfigLoader", diff --git a/xcore/configurations/helper.py b/xcore/configurations/helper.py index 1dc03383..c211d8a1 100644 --- a/xcore/configurations/helper.py +++ b/xcore/configurations/helper.py @@ -18,8 +18,7 @@ def _sub(m: re.Match) -> str: var = m.group(1) resolved = os.environ.get(var) if resolved is None: - logger.warning( - f"Variable d'environnement non définie : ${{{var}}}") + logger.warning(f"Variable d'environnement non définie : ${{{var}}}") return "" return resolved diff --git a/xcore/configurations/loader.py b/xcore/configurations/loader.py index 71459c7e..46c30eb7 100644 --- a/xcore/configurations/loader.py +++ b/xcore/configurations/loader.py @@ -14,10 +14,21 @@ from typing import Any from .helper import _resolve -from .sections import (AppConfig, CacheConfig, DatabaseConfig, LoggingConfig, - MarketplaceConfig, MetricsConfig, ObservabilityConfig, - PluginConfig, SchedulerConfig, SecurityConfig, - ServicesConfig, TracingConfig, XcoreConfig) +from .sections import ( + AppConfig, + CacheConfig, + DatabaseConfig, + LoggingConfig, + MarketplaceConfig, + MetricsConfig, + ObservabilityConfig, + PluginConfig, + SchedulerConfig, + SecurityConfig, + ServicesConfig, + TracingConfig, + XcoreConfig, +) logger = logging.getLogger("xcore.config") @@ -93,8 +104,7 @@ def _load_dotenv(cls, raw: dict[str, Any]) -> None: load_dotenv(dotenv_path=path, override=False) logger.info(f".env loaded : {path}") except ImportError: - logger.warning( - "python-dotenv not installed — pip install python-dotenv") + logger.warning("python-dotenv not installed — pip install python-dotenv") @classmethod def _apply_env_overrides(cls, raw: dict[str, Any]) -> dict[str, Any]: @@ -102,7 +112,7 @@ def _apply_env_overrides(cls, raw: dict[str, Any]) -> dict[str, Any]: for key, value in os.environ.items(): if not key.startswith(prefix): continue - parts = key[len(prefix):].lower().split("__") + parts = key[len(prefix) :].lower().split("__") target = raw for part in parts[:-1]: target = target.setdefault(part, {}) @@ -122,8 +132,7 @@ def _parse(cls, raw: dict[str, Any]) -> XcoreConfig: app=cls._parse_app(raw.get("app", {})), plugins=cls._parse_plugins(raw.get("plugins", {})), services=cls._parse_services(raw.get("services", {})), - observability=cls._parse_observability( - raw.get("observability", {})), + observability=cls._parse_observability(raw.get("observability", {})), security=cls._parse_security(raw.get("security", {})), marketplace=cls._parse_marketplace(raw.get("marketplace", {})), raw=raw, @@ -141,7 +150,7 @@ def _parse_app(d: dict) -> AppConfig: secret_key=sk, plugin_prefix=d.get("plugin_prefix", "/plugin"), plugin_tags=d.get("plugin_tags", []), - server_key=d.get("server_key", 'change-me-in-production'), + server_key=d.get("server_key", "change-me-in-production"), server_key_iterations=d.get("server_key_iterations", 100000), ) diff --git a/xcore/configurations/sections.py b/xcore/configurations/sections.py index aab01ba4..13d4a49d 100644 --- a/xcore/configurations/sections.py +++ b/xcore/configurations/sections.py @@ -61,7 +61,7 @@ class ServicesConfig: class PluginConfig: directory: str = "./plugins" secret_key: bytes = b"change-me-in-production" - strict_trusted: bool = True + strict_trusted: bool = False interval: int = 2 # watcher interval (secondes) entry_point: str = "src/main.py" snapshot: dict[str, Any] = field( @@ -137,8 +137,7 @@ class XcoreConfig: app: AppConfig = field(default_factory=AppConfig) plugins: PluginConfig = field(default_factory=PluginConfig) services: ServicesConfig = field(default_factory=ServicesConfig) - observability: ObservabilityConfig = field( - default_factory=ObservabilityConfig) + observability: ObservabilityConfig = field(default_factory=ObservabilityConfig) security: SecurityConfig = field(default_factory=SecurityConfig) marketplace: MarketplaceConfig = field(default_factory=MarketplaceConfig) raw: dict[str, Any] = field(default_factory=dict) diff --git a/xcore/kernel/api/__init__.py b/xcore/kernel/api/__init__.py index a72d0b44..7caeaa47 100644 --- a/xcore/kernel/api/__init__.py +++ b/xcore/kernel/api/__init__.py @@ -1,5 +1,13 @@ +from .auth import ( + AuthBackend, + AuthPayload, + has_auth_backend, + register_auth_backend, + unregister_auth_backend, +) from .context import PluginContext from .contract import BasePlugin, ExecutionMode, TrustedBase, error, ok +from .rbac import get_auth_backend, get_current_user, get_user_session_id from .versioning import APIVersion, check_compatibility __all__ = [ @@ -11,4 +19,12 @@ "PluginContext", "APIVersion", "check_compatibility", + "get_current_user", + "get_user_session_id", + "AuthBackend", + "AuthPayload", + "register_auth_backend", + "unregister_auth_backend", + "get_auth_backend", + "has_auth_backend", ] diff --git a/xcore/kernel/api/auth.py b/xcore/kernel/api/auth.py index 662d27ff..c16bf8ed 100644 --- a/xcore/kernel/api/auth.py +++ b/xcore/kernel/api/auth.py @@ -3,11 +3,14 @@ from typing import List, NotRequired, Protocol, TypedDict, runtime_checkable +from typing_extensions import Any + class AuthPayload(TypedDict): sub: str roles: NotRequired[List[str]] permissions: NotRequired[List[str]] + user: NotRequired[dict[str, Any]] class RequestAdapter(Protocol): @@ -18,13 +21,11 @@ class RequestAdapter(Protocol): @runtime_checkable class AuthBackend(Protocol): - async def decode_token(self, token: str) -> AuthPayload | None: ... async def extract_token(self, request: RequestAdapter) -> str | None: ... - async def has_permission(self, payload: AuthPayload, - permission: str) -> bool: ... + async def has_permission(self, payload: AuthPayload, permission: str) -> bool: ... # ── Registry singleton ──────────────────────────────────────── diff --git a/xcore/kernel/api/contract.py b/xcore/kernel/api/contract.py index 7473f107..1c0fc154 100644 --- a/xcore/kernel/api/contract.py +++ b/xcore/kernel/api/contract.py @@ -16,7 +16,7 @@ # Literal dispo Python 3.8+ try: - from typing import Awaitable, Callable, Literal + from typing import Literal except ImportError: from typing_extensions import Literal # type: ignore[assignment] @@ -118,10 +118,14 @@ async def _inject_context(self, ctx: Any) -> None: # xcore/kernel/api/contract.py # Ajouter après get_service_as(), avant get_router() - async def call_plugin(self,plugin_name: str,action: str,payload: dict | None = None,) -> dict: + async def call_plugin( + self, + plugin_name: str, + action: str, + payload: dict | None = None, + ) -> dict: if self.ctx is None: - raise RuntimeError( - "call_plugin appelé avant injection du contexte.") + raise RuntimeError("call_plugin appelé avant injection du contexte.") if self.ctx.caller is None: raise RuntimeError( f"[{self.ctx.name}] call_plugin() non disponible " @@ -144,14 +148,15 @@ def get_service(self, name: "Literal['cache']") -> "CacheService": ... # noqa: def get_service(self, name: "Literal['mongodb']") -> "MongoDBAdapter": ... @overload - def get_service( - self, name: "Literal['redisAdapter']") -> "RedisAdapter": ... + def get_service(self, name: "Literal['redisAdapter']") -> "RedisAdapter": ... @overload def get_service(self, name: "Literal['syncdb']") -> "SQLAdapter": ... @overload - def get_service(self, name: "Literal['scheduler']") -> "SchedulerService": ... # noqa: F811 + def get_service( + self, name: "Literal['scheduler']" + ) -> "SchedulerService": ... # noqa: F811 @overload def get_service(self, name: str) -> Any: ... # noqa: F811 @@ -174,7 +179,7 @@ def get_service(self, name: str) -> Any: if self.ctx is None: raise RuntimeError( "Context not injected — plugin not yet loaded. " - "Appeler get_service() depuis on_load() ou handle()." + "called get_service() from on_load() or handle()." ) # Utilise le contexte pour la résolution (inclut scoping via registry) return self.ctx.get_service(name) @@ -229,7 +234,7 @@ async def handle(self, action: str, payload: dict) -> dict: ... def add_state(self) -> dict: """ - Ajoute un middleware à l'exécution. + Ajoute un etat pour l'application :return: { "name": str, "state": app_state diff --git a/xcore/kernel/api/proto.py b/xcore/kernel/api/proto.py index 3f7ed6ea..fba84e4a 100644 --- a/xcore/kernel/api/proto.py +++ b/xcore/kernel/api/proto.py @@ -6,14 +6,20 @@ class EventBus(Protocol): - def on(self, event_name: str, priority: int = 50, - name: str | None = None) -> Callable: ... - async def emit(self, event_name: str, - data: dict[str, Any] | None = None) -> list[Any]: ... + def on( + self, event_name: str, priority: int = 50, name: str | None = None + ) -> Callable: ... + + async def emit( + self, event_name: str, data: dict[str, Any] | None = None + ) -> list[Any]: ... + # Note : on ne met PAS 'clear' ici. def once(self, event_name: str, priority: int = 50) -> Callable: ... - def emit_sync(self, event_name: str, - data: dict[str, Any] | None = None) -> None: ... + + def emit_sync( + self, event_name: str, data: dict[str, Any] | None = None + ) -> None: ... class HookManager(Protocol): @@ -57,8 +63,7 @@ def register(self, name: str) -> Callable: ... -class PluginRegistry(Protocol): - ... +class PluginRegistry(Protocol): ... @dataclass diff --git a/xcore/kernel/api/versioning.py b/xcore/kernel/api/versioning.py index c3176073..c95c67be 100644 --- a/xcore/kernel/api/versioning.py +++ b/xcore/kernel/api/versioning.py @@ -52,7 +52,7 @@ def check_compatibility(framework_version_expr: str, core_version: str) -> bool: part = part.strip() for op in (">=", "<=", ">", "<", "=="): if part.startswith(op): - target = APIVersion.parse(part[len(op):]) + target = APIVersion.parse(part[len(op) :]) ok = { ">=": core >= target, "<=": core <= target, diff --git a/xcore/kernel/events/bus.py b/xcore/kernel/events/bus.py index de967d7d..725be810 100644 --- a/xcore/kernel/events/bus.py +++ b/xcore/kernel/events/bus.py @@ -14,10 +14,14 @@ import inspect import logging import re -from typing import Any, Callable, Pattern +from typing import TYPE_CHECKING, Any, Callable, Pattern from .section import Event, _HandlerEntry +if TYPE_CHECKING: + from ...services.cache import CacheService + + logger = logging.getLogger("xcore.events.bus") @@ -40,7 +44,7 @@ async def welcome(event: Event): ``` """ - def __init__(self) -> None: + def __init__(self, cache: "CacheService" = None) -> None: self._handlers: dict[str, list[_HandlerEntry]] = {} self._wildcard_patterns: dict[str, Pattern] = {} @@ -78,9 +82,13 @@ def subscribe( self._handlers[event_name] = [] # If it's a wildcard, pre-compile and store it - if any(c in event_name for c in "*?[]") and event_name not in self._wildcard_patterns: + if ( + any(c in event_name for c in "*?[]") + and event_name not in self._wildcard_patterns + ): self._wildcard_patterns[event_name] = re.compile( - fnmatch.translate(event_name)) + fnmatch.translate(event_name) + ) entry = _HandlerEntry( handler=handler, diff --git a/xcore/kernel/observability/health.py b/xcore/kernel/observability/health.py index 655509a2..18973c62 100644 --- a/xcore/kernel/observability/health.py +++ b/xcore/kernel/observability/health.py @@ -76,8 +76,7 @@ async def run_all(self, timeout: float = 5.0) -> dict[str, Any]: duration = (time.monotonic() - start) * 1000 results.append( - CheckResult(name=name, status=status, - message=msg, duration_ms=duration) + CheckResult(name=name, status=status, message=msg, duration_ms=duration) ) overall = HealthStatus.HEALTHY diff --git a/xcore/kernel/permissions/engine.py b/xcore/kernel/permissions/engine.py index c93b3cb6..afaf55f1 100644 --- a/xcore/kernel/permissions/engine.py +++ b/xcore/kernel/permissions/engine.py @@ -51,13 +51,11 @@ def load_from_manifest( self._cache.clear() # Invalidate cache on policy change if not raw_permissions: self._policies[plugin_name] = PolicySet.deny_all(plugin_name) - logger.debug( - f"[{plugin_name}] Aucune permission déclarée → DENY ALL") + logger.debug(f"[{plugin_name}] Aucune permission déclarée → DENY ALL") else: ps = PolicySet.from_list(plugin_name, raw_permissions) self._policies[plugin_name] = ps - logger.debug( - f"[{plugin_name}] {len(ps.policies)} règle(s) chargée(s)") + logger.debug(f"[{plugin_name}] {len(ps.policies)} règle(s) chargée(s)") def grant_all(self, plugin_name: str) -> None: """Grant all permissions to a plugin.""" diff --git a/xcore/kernel/permissions/policies.py b/xcore/kernel/permissions/policies.py index a74d60f4..655c0b41 100644 --- a/xcore/kernel/permissions/policies.py +++ b/xcore/kernel/permissions/policies.py @@ -125,8 +125,7 @@ def allow_all(cls, plugin_name: str) -> "PolicySet": """Permissive policy — for internal use / testing only.""" return cls( plugin_name=plugin_name, - policies=[Policy(resource="*", actions=["*"], - effect=PolicyEffect.ALLOW)], + policies=[Policy(resource="*", actions=["*"], effect=PolicyEffect.ALLOW)], ) @classmethod diff --git a/xcore/kernel/runtime/dependency.py b/xcore/kernel/runtime/dependency.py index e6ed2fcf..b267ca94 100644 --- a/xcore/kernel/runtime/dependency.py +++ b/xcore/kernel/runtime/dependency.py @@ -40,8 +40,7 @@ def add_dependency(self, node: str, depends_on: str) -> None: Means 'node' requires 'depends_on'. """ if node not in self._nodes: - raise ValueError( - f"Node '{node}' not in graph. Call add_node first.") + raise ValueError(f"Node '{node}' not in graph. Call add_node first.") if depends_on not in self._nodes: # Note: We might want to allow external dependencies that are not in this batch, # but for topo_sort within a batch, both must be present. @@ -60,12 +59,10 @@ def get_ordered(self) -> list[T]: """ # Kahn's algorithm # Copy in-degrees (number of dependencies each node has) - in_degree = {name: len(deps) - for name, deps in self._dependencies.items()} + in_degree = {name: len(deps) for name, deps in self._dependencies.items()} # Queue of nodes with no dependencies - queue = deque( - [name for name, degree in in_degree.items() if degree == 0]) + queue = deque([name for name, degree in in_degree.items() if degree == 0]) ordered_names = [] while queue: diff --git a/xcore/kernel/runtime/kernel_handler.py b/xcore/kernel/runtime/kernel_handler.py index c264f6d8..9564ad50 100644 --- a/xcore/kernel/runtime/kernel_handler.py +++ b/xcore/kernel/runtime/kernel_handler.py @@ -28,7 +28,8 @@ class KernelHandler: def __init__(self, ctx: "KernelContext", supervisor: "PluginSupervisor") -> None: self._ctx = ctx - self._supervisor = supervisor # ref circulaire volontaire (weak si besoin) + # ref circulaire volontaire (weak si besoin) + self._supervisor = supervisor @property def is_available(self) -> bool: @@ -101,5 +102,5 @@ async def _xfow_integration(self, payload: dict) -> dict: # Enregistrement déclaratif (évite les répétitions de string) KernelHandler._ACTIONS = { "plugin.list": KernelHandler._plugins_list, - "xflow.integration": KernelHandler._xfow_integration + "xflow.integration": KernelHandler._xfow_integration, } diff --git a/xcore/kernel/runtime/lifecycle.py b/xcore/kernel/runtime/lifecycle.py index 5c034471..15350b81 100644 --- a/xcore/kernel/runtime/lifecycle.py +++ b/xcore/kernel/runtime/lifecycle.py @@ -38,8 +38,7 @@ class LifecycleManager: Manages the complete lifecycle of a Trusted plugin in memory. """ - PROTECTED_SERVICES = {"db", "cache", - "scheduler", "events", "hooks", "database"} + PROTECTED_SERVICES = {"db", "cache", "scheduler", "events", "hooks", "database"} def __init__( self, @@ -85,8 +84,7 @@ def uptime(self) -> float | None: return None if self._loaded_at is None else time.monotonic() - self._loaded_at def _on_state_change(self, old: PluginState, new: PluginState) -> None: - logger.debug( - f"[{self.manifest.name}] état : {old.value} → {new.value}") + logger.debug(f"[{self.manifest.name}] état : {old.value} → {new.value}") if self._events: self._events.emit_sync( f"plugin.{self.manifest.name}.state_changed", @@ -118,8 +116,7 @@ async def load(self) -> None: ) except Exception as e: self._sm.transition("error") - raise LoadError( - f"[{self.manifest.name}] Échec chargement : {e}") from e + raise LoadError(f"[{self.manifest.name}] Échec chargement : {e}") from e async def _do_load(self) -> None: entry = self.manifest.plugin_dir / self.manifest.entry_point @@ -187,8 +184,7 @@ async def _invoke_hooks(self, hook_names: list[str]) -> None: else: hook() except Exception as e: - logger.error( - f"[{self.manifest.name}] Erreur hook {name} : {e}") + logger.error(f"[{self.manifest.name}] Erreur hook {name} : {e}") raise def _instantiate(self, cls) -> BasePlugin: @@ -245,8 +241,7 @@ async def call(self, action: str, payload: dict) -> dict: raise return ( - result if isinstance(result, dict) else { - "status": "ok", "result": result} + result if isinstance(result, dict) else {"status": "ok", "result": result} ) # ── Reload ──────────────────────────────────────────────── @@ -264,8 +259,7 @@ async def reload(self) -> None: logger.info(f"[{self.manifest.name}] reloaded") except Exception as e: self._sm.transition("error") - raise LoadError( - f"[{self.manifest.name}] failed reload : {e}") from e + raise LoadError(f"[{self.manifest.name}] failed reload : {e}") from e # ── Unload ──────────────────────────────────────────────── @@ -336,8 +330,7 @@ def _collect_middlewares(self) -> None: f"({len(self.plugin_middlewares)} middleware(s))" ) except Exception as e: - logger.error( - f"[{self.manifest.name}] get_middlewares() erreur : {e}") + logger.error(f"[{self.manifest.name}] get_middlewares() erreur : {e}") # ── Propagation des services (fix #3 v1) ────────────────── @@ -389,8 +382,7 @@ def propagate_services(self, *, is_reload: bool = False) -> dict: else: # 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"} + protected = {"db", "cache", "scheduler", "events", "hooks", "database"} collisions = set(instance_services.keys()) & protected if collisions: raise PermissionError( @@ -417,8 +409,7 @@ def propagate_services(self, *, is_reload: bool = False) -> dict: f"{sorted(instance_services.keys())}" ) else: - new_keys = set(instance_services.keys()) - \ - set(self._services.keys()) + new_keys = set(instance_services.keys()) - set(self._services.keys()) for k in new_keys: self._services[k] = instance_services[k] if new_keys: diff --git a/xcore/kernel/runtime/loader.py b/xcore/kernel/runtime/loader.py index 16a8f0d0..8f91a20f 100644 --- a/xcore/kernel/runtime/loader.py +++ b/xcore/kernel/runtime/loader.py @@ -96,8 +96,16 @@ async def load_all(self) -> dict[str, list[str]]: if not d.is_dir() or d.name.startswith("_"): continue try: - manifest = self._validator.load_and_validate(d) - manifests.append(manifest) + manifest, validate_version, frameversion = ( + self._validator.load_and_validate(d) + ) + if validate_version: + manifests.append(manifest) + else: + logger.warning( + f"version du framework incompatible: {manifest.name} version requise" + f": {manifest.framework_version} version actuelle: {frameversion}" + ) except Exception as e: logger.warning(f"[{d.name}] Manifeste invalide : {e}") skipped.append(d.name) diff --git a/xcore/kernel/runtime/middlewares/middleware.py b/xcore/kernel/runtime/middlewares/middleware.py index 14e0b7d6..77f9e78f 100644 --- a/xcore/kernel/runtime/middlewares/middleware.py +++ b/xcore/kernel/runtime/middlewares/middleware.py @@ -38,9 +38,7 @@ def __init__(self, middlewares: List[Middleware], final_handler: Callable): self._middlewares = list(middlewares) self._final_handler = final_handler # Compilation de la chaîne au démarrage pour minimiser l'overhead. - self._compiled_chain = self._compile_pipeline( - middlewares, final_handler - ) + self._compiled_chain = self._compile_pipeline(middlewares, final_handler) def _compile_pipeline( self, middlewares: List[Middleware], final_handler: Callable diff --git a/xcore/kernel/runtime/middlewares/middleware_registry.py b/xcore/kernel/runtime/middlewares/middleware_registry.py index 4ab28ffd..72a152af 100644 --- a/xcore/kernel/runtime/middlewares/middleware_registry.py +++ b/xcore/kernel/runtime/middlewares/middleware_registry.py @@ -21,19 +21,17 @@ class MiddlewareRegistry: def __init__(self) -> None: # factory: (context_dict) -> Middleware - self._factories: dict[str, Callable[[ - dict[str, Any]], "Middleware"]] = {} + self._factories: dict[str, Callable[[dict[str, Any]], "Middleware"]] = {} - def register(self, name: str, factory: Callable[[dict[str, Any]], "Middleware"]) -> None: + def register( + self, name: str, factory: Callable[[dict[str, Any]], "Middleware"] + ) -> None: """Registers a middleware factory.""" self._factories[name] = factory logger.debug(f"Middleware factory registered: {name}") def create_pipeline( - self, - names: list[str], - context: dict[str, Any], - final_handler: Callable + self, names: list[str], context: dict[str, Any], final_handler: Callable ) -> "MiddlewarePipeline": """ Creates a MiddlewarePipeline using the registered factories. diff --git a/xcore/kernel/runtime/middlewares/retry.py b/xcore/kernel/runtime/middlewares/retry.py index 1e76fafe..46c9baad 100644 --- a/xcore/kernel/runtime/middlewares/retry.py +++ b/xcore/kernel/runtime/middlewares/retry.py @@ -33,15 +33,15 @@ async def __call__( # Extraction sécurisée des paramètres de retry retry_params = getattr(retry_cfg, "retry", None) if retry_cfg else None - max_attempts = getattr( - retry_params, "max_attempts", 1) if retry_params else 1 - backoff = getattr(retry_params, "backoff_seconds", - 0.0) if retry_params else 0.0 + max_attempts = getattr(retry_params, "max_attempts", 1) if retry_params else 1 + backoff = getattr(retry_params, "backoff_seconds", 0.0) if retry_params else 0.0 last_err = None for attempt in range(1, max_attempts + 1): try: - result = await next_call(plugin_name, action, payload, handler, **kwargs) + result = await next_call( + plugin_name, action, payload, handler, **kwargs + ) # Si le résultat indique une erreur de type exception capturée par le handler if ( diff --git a/xcore/kernel/sandbox/__init__.py b/xcore/kernel/sandbox/__init__.py index db28e754..fe345c9a 100644 --- a/xcore/kernel/sandbox/__init__.py +++ b/xcore/kernel/sandbox/__init__.py @@ -1,5 +1,4 @@ -from .ipc import (IPCChannel, IPCError, IPCProcessDead, IPCResponse, - IPCTimeoutError) +from .ipc import IPCChannel, IPCError, IPCProcessDead, IPCResponse, IPCTimeoutError from .isolation import DiskQuotaExceeded, DiskWatcher, MemoryLimiter from .limits import RateLimiter, RateLimiterRegistry, RateLimitExceeded from .process_manager import SandboxConfig, SandboxProcessManager diff --git a/xcore/kernel/sandbox/isolation.py b/xcore/kernel/sandbox/isolation.py index b154a43e..d8c86f44 100644 --- a/xcore/kernel/sandbox/isolation.py +++ b/xcore/kernel/sandbox/isolation.py @@ -45,8 +45,7 @@ def stats(self) -> dict: "used_mb": self.current_size_mb(), "max_mb": self._max_disk_mb, "used_pct": ( - round(used / self._max_bytes * 100, - 1) if self._max_bytes else 0 + round(used / self._max_bytes * 100, 1) if self._max_bytes else 0 ), "ok": used <= self._max_bytes if self._max_bytes else True, } diff --git a/xcore/kernel/sandbox/middlewares/middleware.py b/xcore/kernel/sandbox/middlewares/middleware.py index 14e0b7d6..77f9e78f 100644 --- a/xcore/kernel/sandbox/middlewares/middleware.py +++ b/xcore/kernel/sandbox/middlewares/middleware.py @@ -38,9 +38,7 @@ def __init__(self, middlewares: List[Middleware], final_handler: Callable): self._middlewares = list(middlewares) self._final_handler = final_handler # Compilation de la chaîne au démarrage pour minimiser l'overhead. - self._compiled_chain = self._compile_pipeline( - middlewares, final_handler - ) + self._compiled_chain = self._compile_pipeline(middlewares, final_handler) def _compile_pipeline( self, middlewares: List[Middleware], final_handler: Callable diff --git a/xcore/kernel/sandbox/middlewares/middleware_registry.py b/xcore/kernel/sandbox/middlewares/middleware_registry.py index 4ab28ffd..72a152af 100644 --- a/xcore/kernel/sandbox/middlewares/middleware_registry.py +++ b/xcore/kernel/sandbox/middlewares/middleware_registry.py @@ -21,19 +21,17 @@ class MiddlewareRegistry: def __init__(self) -> None: # factory: (context_dict) -> Middleware - self._factories: dict[str, Callable[[ - dict[str, Any]], "Middleware"]] = {} + self._factories: dict[str, Callable[[dict[str, Any]], "Middleware"]] = {} - def register(self, name: str, factory: Callable[[dict[str, Any]], "Middleware"]) -> None: + def register( + self, name: str, factory: Callable[[dict[str, Any]], "Middleware"] + ) -> None: """Registers a middleware factory.""" self._factories[name] = factory logger.debug(f"Middleware factory registered: {name}") def create_pipeline( - self, - names: list[str], - context: dict[str, Any], - final_handler: Callable + self, names: list[str], context: dict[str, Any], final_handler: Callable ) -> "MiddlewarePipeline": """ Creates a MiddlewarePipeline using the registered factories. diff --git a/xcore/kernel/sandbox/middlewares/retry.py b/xcore/kernel/sandbox/middlewares/retry.py index 1e76fafe..46c9baad 100644 --- a/xcore/kernel/sandbox/middlewares/retry.py +++ b/xcore/kernel/sandbox/middlewares/retry.py @@ -33,15 +33,15 @@ async def __call__( # Extraction sécurisée des paramètres de retry retry_params = getattr(retry_cfg, "retry", None) if retry_cfg else None - max_attempts = getattr( - retry_params, "max_attempts", 1) if retry_params else 1 - backoff = getattr(retry_params, "backoff_seconds", - 0.0) if retry_params else 0.0 + max_attempts = getattr(retry_params, "max_attempts", 1) if retry_params else 1 + backoff = getattr(retry_params, "backoff_seconds", 0.0) if retry_params else 0.0 last_err = None for attempt in range(1, max_attempts + 1): try: - result = await next_call(plugin_name, action, payload, handler, **kwargs) + result = await next_call( + plugin_name, action, payload, handler, **kwargs + ) # Si le résultat indique une erreur de type exception capturée par le handler if ( diff --git a/xcore/kernel/sandbox/process_manager.py b/xcore/kernel/sandbox/process_manager.py index ea7879c6..d07f612a 100644 --- a/xcore/kernel/sandbox/process_manager.py +++ b/xcore/kernel/sandbox/process_manager.py @@ -8,13 +8,12 @@ import asyncio import contextlib import logging -import os import sys import time from dataclasses import dataclass from enum import Enum from pathlib import Path -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING if TYPE_CHECKING: from ..runtime.loader import PluginLoader @@ -88,7 +87,7 @@ async def start(self) -> None: return self._state = ProcessState.STARTING await self._spawn() - # await self._ping_check() + await self._ping_check() self._state = ProcessState.RUNNING self._started_at = time.monotonic() self._restarts = 0 diff --git a/xcore/kernel/security/scanner_core.cpython-312-x86_64-linux-gnu.so b/xcore/kernel/security/scanner_core.cpython-312-x86_64-linux-gnu.so deleted file mode 100755 index 15265d94..00000000 Binary files a/xcore/kernel/security/scanner_core.cpython-312-x86_64-linux-gnu.so and /dev/null differ diff --git a/xcore/kernel/security/scanner_core.cpython-312-x86_64-linux-gnu.so.bak b/xcore/kernel/security/scanner_core.cpython-312-x86_64-linux-gnu.so.bak deleted file mode 100755 index 15265d94..00000000 Binary files a/xcore/kernel/security/scanner_core.cpython-312-x86_64-linux-gnu.so.bak and /dev/null differ diff --git a/xcore/kernel/security/section.py b/xcore/kernel/security/section.py index fa66a0df..1c41c087 100644 --- a/xcore/kernel/security/section.py +++ b/xcore/kernel/security/section.py @@ -95,6 +95,7 @@ "asyncio", "logging", "__future__", + "xcore", "xcore.*", "xcore.sdk.*", } @@ -136,8 +137,7 @@ def __init__(self, raw, mode, env, requires: list, plugin_dir): self.env = env # Convertit les dépendances en PluginDependency si ce sont des strings self.requires = [ - dep if isinstance( - dep, PluginDependency) else PluginDependency.from_raw(dep) + dep if isinstance(dep, PluginDependency) else PluginDependency.from_raw(dep) for dep in requires ] self.plugin_dir = plugin_dir @@ -150,8 +150,7 @@ def __init__(self, raw, mode, env, requires: list, plugin_dir): self.resources = SimpleNamespace( timeout_seconds=10, max_memory_mb=128, max_disk_mb=50, rate_limit=rl ) - hc = SimpleNamespace( - enabled=True, interval_seconds=30, timeout_seconds=3) + hc = SimpleNamespace(enabled=True, interval_seconds=30, timeout_seconds=3) retry = SimpleNamespace(max_attempts=1, backoff_seconds=0.0) self.runtime = SimpleNamespace(health_check=hc, retry=retry) fs = SimpleNamespace(allowed_paths=["data/"], denied_paths=["src/"]) diff --git a/xcore/kernel/security/signature.py b/xcore/kernel/security/signature.py index e0c9f6fa..34efe418 100644 --- a/xcore/kernel/security/signature.py +++ b/xcore/kernel/security/signature.py @@ -29,7 +29,10 @@ class SignatureError(Exception): def _should_ignore(path: Path, root: Path) -> bool: rel = path.relative_to(root) - if any(part in SECURITY_IGNORE for part in rel.parts) or path.name in SECURITY_IGNORE: + if ( + any(part in SECURITY_IGNORE for part in rel.parts) + or path.name in SECURITY_IGNORE + ): return True if path.suffix in {".pyc", ".pyo"}: @@ -64,13 +67,11 @@ def _compute_hmac(manifest, secret_key: bytes) -> str: src_dir = (root / entry_path.parent).resolve() if not src_dir.exists(): - raise SignatureError( - f"Répertoire source {src_dir} introuvable dans {root}") + raise SignatureError(f"Répertoire source {src_dir} introuvable dans {root}") # Ensure src_dir is within root or is the root itself if not src_dir.is_relative_to(root): - raise SignatureError( - f"Répertoire source {src_dir} hors du dossier plugin.") + raise SignatureError(f"Répertoire source {src_dir} hors du dossier plugin.") files = sorted( p for p in src_dir.rglob("*") if p.is_file() and not _should_ignore(p, root) @@ -115,14 +116,12 @@ def verify_plugin(manifest, secret_key: bytes) -> None: sig_path = manifest.plugin_dir / SIG_FILENAME if not sig_path.exists(): - raise SignatureError( - f"[{manifest.name}] Signature manquante ({SIG_FILENAME}).") + raise SignatureError(f"[{manifest.name}] Signature manquante ({SIG_FILENAME}).") try: sig_data = json.loads(sig_path.read_text()) except Exception as e: - raise SignatureError( - f"[{manifest.name}] Fichier .sig illisible : {e}") from e + raise SignatureError(f"[{manifest.name}] Fichier .sig illisible : {e}") from e stored = sig_data.get("digest") if not stored: diff --git a/xcore/kernel/security/validation.py b/xcore/kernel/security/validation.py index a93d2d05..c211b925 100644 --- a/xcore/kernel/security/validation.py +++ b/xcore/kernel/security/validation.py @@ -18,19 +18,24 @@ import json import os import re -from dataclasses import dataclass, field from pathlib import Path from xcore.sdk.plugin_base import PluginDependency -from .section import (DEFAULT_ALLOWED, DEFAULT_FORBIDDEN, FORBIDDEN_ATTRIBUTES, - FORBIDDEN_BUILTINS, ScanResult, _SimpleManifest) +from .section import ( + DEFAULT_ALLOWED, + DEFAULT_FORBIDDEN, + FORBIDDEN_ATTRIBUTES, + FORBIDDEN_BUILTINS, + ScanResult, + _SimpleManifest, +) # ── Chargement de l'extension C++ (optionnel) ──────────────── try: - from .scanner_core import \ - ImportClassifier as _CppClassifier # type: ignore + from .scanner_core import ImportClassifier as _CppClassifier # type: ignore + _CPP_AVAILABLE = True except ImportError: _CppClassifier = None @@ -45,6 +50,10 @@ class ManifestError(Exception): pass +class FrameworkVersionVersion(Exception): + pass + + _ENV_VAR_RE = re.compile(r"^\$\{(.+)\}$") # Détecte un "vrai" nom de module Python : @@ -91,10 +100,9 @@ def load_and_validate(self, plugin_dir: Path): for field_name in ("name", "version"): if not raw.get(field_name): - raise ManifestError( - f"Champ obligatoire manquant : '{field_name}'") + raise ManifestError(f"Champ obligatoire manquant : '{field_name}'") - check_compatibility( + validate_version = check_compatibility( raw.get("framework_version", f"=={__version__}"), __version__ ) @@ -108,8 +116,7 @@ def load_and_validate(self, plugin_dir: Path): ) from e self._inject_dotenv(raw.get("envconfiguration"), plugin_dir) - resolved_env = {k: _resolve_env(v) - for k, v in raw.get("env", {}).items()} + resolved_env = {k: _resolve_env(v) for k, v in raw.get("env", {}).items()} requires_raw = raw.get("requires", []) or [] if not isinstance(requires_raw, list): @@ -122,7 +129,11 @@ def load_and_validate(self, plugin_dir: Path): except ValueError as e: raise ManifestError(f"Dépendance invalide: {e}") from e - return _build_manifest(raw, mode, resolved_env, requires, plugin_dir) + return ( + _build_manifest(raw, mode, resolved_env, requires, plugin_dir), + validate_version, + __version__, + ) def _read_raw(self, plugin_dir: Path) -> dict: for fname, loader in [("plugin.yaml", self._yaml), ("plugin.json", self._json)]: @@ -153,8 +164,7 @@ def _inject_dotenv(cfg: dict | None, plugin_dir: Path) -> None: env_file = cfg.get("env_file", ".env") env_path = (plugin_dir / env_file).resolve() if not env_path.is_relative_to(plugin_dir.resolve()): - raise ManifestError( - f"Tentative de traversal via env_file : {env_file!r}") + raise ManifestError(f"Tentative de traversal via env_file : {env_file!r}") if not env_path.exists(): raise ManifestError( f"envconfiguration.inject=true mais '{env_path}' introuvable." @@ -220,6 +230,96 @@ def _parse_allowed_imports(raw_list: list[str]) -> tuple[set[str], list[str]]: return exact, prefixes, skipped +# ───────────────────────────────────────────────────────────── +# _SecurityVisitor — Utilisé pour les tests unitaires fins +# ───────────────────────────────────────────────────────────── + + +class _SecurityVisitor(ast.NodeVisitor): + """ + Visiteur AST pour la validation de sécurité. + Note : ASTScanner utilise maintenant une approche plus directe pour + les scans de répertoires complets, mais ce visiteur reste utile pour + les tests unitaires et les validations fines. + """ + + def __init__( + self, + forbidden: set[str], + allowed: set[str], + filename: str, + path: Path, + ): + self.forbidden = forbidden + # Normalise les allowed une seule fois + self.allowed_exact, self.allowed_prefixes, _ = _parse_allowed_imports( + list(allowed) + ) + self.filename = filename + self.path = path + self.errors: list[str] = [] + self.warnings: list[str] = [] + self.forbidden_builtins = FORBIDDEN_BUILTINS + + def _is_allowed(self, module: str) -> bool: + if module in self.allowed_exact: + return True + root = module.split(".")[0] + if root in self.allowed_exact: + return True + for prefix in self.allowed_prefixes: + if module == prefix or module.startswith(prefix + "."): + return True + return False + + def _check(self, module: str, lineno: int) -> None: + root = module.split(".")[0] + loc = f"{self.path}:{lineno}: " + if root in self.forbidden: + self.errors.append(loc + f"import interdit — '{module}'") + elif not self._is_allowed(module): + self.warnings.append(loc + f"import non whitelisté — '{module}'") + + def visit_Import(self, node: ast.Import) -> None: + for alias in node.names: + self._check(alias.name, node.lineno) + self.generic_visit(node) + + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + if node.level and node.level > 0: + return + if node.module: + self._check(node.module, node.lineno) + # Check aliases to catch 'from ... import os' bypasses + for alias in node.names: + if alias.name in self.forbidden: + loc = f"{self.path}:{node.lineno}: " + self.errors.append(loc + f"import interdit (alias) — '{alias.name}'") + self.generic_visit(node) + + def visit_Name(self, node: ast.Name) -> None: + if node.id in self.forbidden_builtins: + self.errors.append( + f"{self.path}:{node.lineno}: built-in interdit : {node.id!r}" + ) + elif node.id in self.forbidden: + self.errors.append( + f"{self.path}:{node.lineno}: accès interdit : {node.id!r}" + ) + self.generic_visit(node) + + def visit_Attribute(self, node: ast.Attribute) -> None: + if node.attr in FORBIDDEN_ATTRIBUTES: + self.errors.append( + f"{self.path}:{node.lineno}: attribut interdit : {node.attr!r}" + ) + elif node.attr in self.forbidden: + self.errors.append( + f"{self.path}:{node.lineno}: accès interdit via attribut : {node.attr!r}" + ) + self.generic_visit(node) + + # ───────────────────────────────────────────────────────────── # ASTScanner — orchestration Python + C++ # ───────────────────────────────────────────────────────────── @@ -244,8 +344,7 @@ def __init__( allowed_raw = DEFAULT_ALLOWED | (extra_allowed or set()) # Sépare exact vs wildcards une seule fois à la construction - self._allowed_exact: set[str] = { - p for p in allowed_raw if not p.endswith(".*")} + self._allowed_exact: set[str] = {p for p in allowed_raw if not p.endswith(".*")} self._allowed_prefixes: list[str] = [ p[:-2] for p in allowed_raw if p.endswith(".*") ] @@ -286,8 +385,7 @@ def scan( entry_path = (plugin_dir / entry_point).resolve() if not entry_path.is_relative_to(plugin_dir): - result.add_error( - f"Entry point hors du dossier plugin : {entry_point!r}") + result.add_error(f"Entry point hors du dossier plugin : {entry_point!r}") return result if not entry_path.exists(): result.add_error(f"Entry point introuvable : {entry_point!r}") @@ -316,8 +414,7 @@ def scan( extra_allowed=extra_exact | {f"{p}.*" for p in m_prefixes}, ) else: - scanner = self if not extra_exact else ASTScanner( - extra_allowed=extra_exact) + scanner = self if not extra_exact else ASTScanner(extra_allowed=extra_exact) py_files = sorted(set(src_dir.rglob("*.py")) | {entry_path}) if not py_files: @@ -326,8 +423,7 @@ def scan( # ── Scan des imports (C++ ou Python) ───────────────── if scanner._cpp is not None: - cpp_results = scanner._cpp.scan_directory( - str(src_dir), local_modules) + cpp_results = scanner._cpp.scan_directory(str(src_dir), local_modules) for fr in cpp_results: for e in fr.errors: result.add_error(e) @@ -344,6 +440,16 @@ def scan( return result + # ── Propriétés pour compatibilité tests ─────────────────── + + @property + def forbidden(self) -> set[str]: + return self._forbidden + + @property + def allowed(self) -> set[str]: + return self._allowed_exact | {f"{p}.*" for p in self._allowed_prefixes} + # ── Fallback Python ────────────────────────────────────── def _scan_imports_python( @@ -365,8 +471,7 @@ def _scan_imports_python( for node in ast.walk(tree): if isinstance(node, ast.Import): for alias in node.names: - self._check_py(alias.name, node.lineno, - path, result, local_modules) + self._check_py(alias.name, node.lineno, path, result, local_modules) elif isinstance(node, ast.ImportFrom): # Seul node.module est le module source. @@ -448,15 +553,14 @@ def _check_builtins_and_attrs( f"{path}:{node.lineno}: built-in interdit : {node.id!r}" ) elif node.id in forbidden: - result.add_error( - f"{path}:{node.lineno}: nom interdit : {node.id!r}") + result.add_error(f"{path}:{node.lineno}: accès interdit : {node.id!r}") elif isinstance(node, ast.Attribute): if node.attr in FORBIDDEN_ATTRIBUTES: result.add_error( - f"{path}:{node.lineno}: attribut sensible interdit : {node.attr!r}" + f"{path}:{node.lineno}: attribut interdit : {node.attr!r}" ) elif node.attr in forbidden: result.add_error( - f"{path}:{node.lineno}: attribut de module interdit : {node.attr!r}" + f"{path}:{node.lineno}: accès interdit via attribut : {node.attr!r}" ) diff --git a/xcore/registry/__init__.py b/xcore/registry/__init__.py index 3582e7db..53789d1f 100644 --- a/xcore/registry/__init__.py +++ b/xcore/registry/__init__.py @@ -2,5 +2,4 @@ from .resolver import DependencyResolver from .versioning import VersionConstraint, satisfies -__all__ = ["PluginRegistry", "DependencyResolver", - "VersionConstraint", "satisfies"] +__all__ = ["PluginRegistry", "DependencyResolver", "VersionConstraint", "satisfies"] diff --git a/xcore/registry/index.py b/xcore/registry/index.py index b09bab71..a584dc08 100644 --- a/xcore/registry/index.py +++ b/xcore/registry/index.py @@ -95,10 +95,11 @@ def register_service( f"[registry] service '{service_name}' enregistré par '{plugin_name}' (scope: {scope})" ) - def register_core_service(self, name: str, obj: Any, metadata: dict | None = None) -> None: + def register_core_service( + self, name: str, obj: Any, metadata: dict | None = None + ) -> None: """Enregistre un service noyau (protégé par défaut).""" - self.register_service("kernel", name, obj, - metadata=metadata, scope="protected") + self.register_service("kernel", name, obj, metadata=metadata, scope="protected") def get_service(self, service_name: str, requester: str | None = None) -> Any: """ diff --git a/xcore/registry/resolver.py b/xcore/registry/resolver.py index 3041bab2..c249a541 100644 --- a/xcore/registry/resolver.py +++ b/xcore/registry/resolver.py @@ -63,8 +63,7 @@ def resolve(self) -> list[str]: reverse[dep].append(name) in_degree[name] += 1 - queue: deque[str] = deque( - sorted(n for n, d in in_degree.items() if d == 0)) + queue: deque[str] = deque(sorted(n for n, d in in_degree.items() if d == 0)) result: list[str] = [] while queue: diff --git a/xcore/sdk/__init__.py b/xcore/sdk/__init__.py index 9afde079..d6fb2797 100644 --- a/xcore/sdk/__init__.py +++ b/xcore/sdk/__init__.py @@ -6,14 +6,20 @@ from xcore.sdk import PluginManifest """ -from ..kernel.api.contract import (BasePlugin, ExecutionMode, TrustedBase, - error, ok) +from ..kernel.api.contract import BasePlugin, ExecutionMode, TrustedBase, error, ok from ..kernel.api.rbac import RBACChecker, require_permission, require_role from .adapter.asyncsql import BaseAsyncRepository from .adapter.syncsql import BaseSyncRepository -from .decorators import (AutoDispatchMixin, RoutedPlugin, action, - require_service, route, sandboxed, trusted, - validate_payload) +from .decorators import ( + AutoDispatchMixin, + RoutedPlugin, + action, + require_service, + route, + sandboxed, + trusted, + validate_payload, +) from .plugin_base import PluginManifest, ResourceConfig, RuntimeConfig from .routers import RouterRegistry diff --git a/xcore/sdk/decorators.py b/xcore/sdk/decorators.py index 268f46db..615376ca 100644 --- a/xcore/sdk/decorators.py +++ b/xcore/sdk/decorators.py @@ -25,7 +25,7 @@ async def save(self, payload: dict) -> dict: import functools import inspect import logging -from typing import Any, Callable, Literal, Optional, Type +from typing import Callable, Literal, Type from pydantic import BaseModel, ValidationError, create_model @@ -125,6 +125,7 @@ async def wrapper(self, payload: dict, *args, **kwargs): return decorator + def route( path: str, method: str = "GET", diff --git a/xcore/sdk/plugin_base.py b/xcore/sdk/plugin_base.py index 6a444456..e723645e 100644 --- a/xcore/sdk/plugin_base.py +++ b/xcore/sdk/plugin_base.py @@ -138,7 +138,7 @@ def _parse(self, constraint: str) -> list[tuple[str, tuple[int, ...]]]: for part in parts: for op in (">=", "<=", ">", "<", "==", "!=", "^", "~"): if part.startswith(op): - version_str = part[len(op):].strip() + version_str = part[len(op) :].strip() version = self._to_tuple(version_str) specs.append((op, version)) break diff --git a/xcore/services/cache/backends/memory.py b/xcore/services/cache/backends/memory.py index ffce1474..336fa6b9 100644 --- a/xcore/services/cache/backends/memory.py +++ b/xcore/services/cache/backends/memory.py @@ -51,8 +51,7 @@ async def get(self, key: str) -> Any | None: async def set(self, key: str, value: Any, ttl: int | None = None) -> None: effective_ttl = ttl if ttl is not None else self._ttl - expires_at = (time.monotonic() + - effective_ttl) if effective_ttl > 0 else None + expires_at = (time.monotonic() + effective_ttl) if effective_ttl > 0 else None if key in self._store: self._store.move_to_end(key) @@ -95,8 +94,7 @@ async def mget(self, keys: list[str]) -> dict[str, Any]: async def mset(self, mapping: dict[str, Any], ttl: int | None = None) -> None: """Définit plusieurs clés d'un coup.""" effective_ttl = ttl if ttl is not None else self._ttl - expires_at = (time.monotonic() + - effective_ttl) if effective_ttl > 0 else None + expires_at = (time.monotonic() + effective_ttl) if effective_ttl > 0 else None for k, v in mapping.items(): if k in self._store: diff --git a/xcore/services/cache/backends/redis.py b/xcore/services/cache/backends/redis.py index 8698decf..ebc333ba 100644 --- a/xcore/services/cache/backends/redis.py +++ b/xcore/services/cache/backends/redis.py @@ -34,8 +34,7 @@ async def connect(self) -> None: try: import redis.asyncio as aioredis except ImportError as e: - raise ImportError( - "redis non installé — pip install redis[asyncio]") from e + raise ImportError("redis non installé — pip install redis[asyncio]") from e self._client = aioredis.from_url(self._url, decode_responses=True) await self._client.ping() @@ -54,8 +53,7 @@ async def get(self, key: str) -> Any | None: async def set(self, key: str, value: Any, ttl: int | None = None) -> None: ex = ttl if ttl is not None else self._ttl - raw = json.dumps(value) if not isinstance( - value, (str, bytes)) else value + raw = json.dumps(value) if not isinstance(value, (str, bytes)) else value await self._client.set(key, raw, ex=ex if ex > 0 else None) async def mget(self, keys: list[str]) -> dict[str, Any]: @@ -82,8 +80,7 @@ async def mset(self, mapping: dict[str, Any], ttl: int | None = None) -> None: async with self._client.pipeline() as pipe: for key, value in mapping.items(): raw = ( - json.dumps(value) if not isinstance( - value, (str, bytes)) else value + json.dumps(value) if not isinstance(value, (str, bytes)) else value ) pipe.set(key, raw, ex=ex if ex > 0 else None) await pipe.execute() diff --git a/xcore/services/cache/service.py b/xcore/services/cache/service.py index e8c14cf4..06b2e42b 100644 --- a/xcore/services/cache/service.py +++ b/xcore/services/cache/service.py @@ -48,8 +48,7 @@ async def init(self) -> None: if backend_type == "redis": if not self._config.url: - raise ValueError( - "CacheConfig.url obligatoire pour le backend Redis") + raise ValueError("CacheConfig.url obligatoire pour le backend Redis") from .backends.redis import RedisCacheBackend self._backend = RedisCacheBackend( diff --git a/xcore/services/container.py b/xcore/services/container.py index b84b0f0e..9d687f8c 100644 --- a/xcore/services/container.py +++ b/xcore/services/container.py @@ -119,11 +119,20 @@ class ServiceContainer: await container.shutdown() """ + # Pour compatibilité avec les anciens tests + DEFAULT_PROVIDERS = [ + DatabaseServiceProvider, + CacheServiceProvider, + SchedulerServiceProvider, + ExtensionServiceProvider, + ] + def __init__( self, config: "ServicesConfig", providers: list[BaseServiceProvider] | None = None, ) -> None: + self._config = config self._services: dict[str, BaseService] = {} self._raw: dict[str, Any] = {} @@ -203,7 +212,7 @@ def get(self, name: str) -> T: return self._raw[name] # Recherche dans les providers (Lazy loading) - for provider_name, provider in self._lazy_providers.items(): + for _, provider in self._lazy_providers.items(): if hasattr(provider, "provide"): svc = provider.provide(name) if svc is not None: diff --git a/xcore/services/database/adapters/async_sql.py b/xcore/services/database/adapters/async_sql.py index 60dc5fd9..f4e1187f 100644 --- a/xcore/services/database/adapters/async_sql.py +++ b/xcore/services/database/adapters/async_sql.py @@ -32,8 +32,7 @@ def __init__(self, name: str, cfg: "DatabaseConfig") -> None: async def connect(self) -> None: try: - from sqlalchemy.ext.asyncio import (AsyncSession, - create_async_engine) + from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from sqlalchemy.orm import sessionmaker except ImportError as e: raise ImportError( diff --git a/xcore/services/database/adapters/redis.py b/xcore/services/database/adapters/redis.py index f23d96e1..f4368e3b 100644 --- a/xcore/services/database/adapters/redis.py +++ b/xcore/services/database/adapters/redis.py @@ -33,8 +33,7 @@ async def connect(self) -> None: try: import redis.asyncio as aioredis except ImportError as e: - raise ImportError( - "redis non installé — pip install redis[asyncio]") from e + raise ImportError("redis non installé — pip install redis[asyncio]") from e pool = aioredis.ConnectionPool.from_url( self.url, diff --git a/xcore/services/database/adapters/sql.py b/xcore/services/database/adapters/sql.py index 218d690e..3fb894aa 100644 --- a/xcore/services/database/adapters/sql.py +++ b/xcore/services/database/adapters/sql.py @@ -40,8 +40,7 @@ async def connect(self) -> None: from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker except ImportError as e: - raise ImportError( - "sqlalchemy non installé — pip install sqlalchemy") from e + raise ImportError("sqlalchemy non installé — pip install sqlalchemy") from e self._engine = create_engine( self.url, diff --git a/xcore/services/database/migrations.py b/xcore/services/database/migrations.py index f318df4f..6edee287 100644 --- a/xcore/services/database/migrations.py +++ b/xcore/services/database/migrations.py @@ -17,7 +17,7 @@ import logging from pathlib import Path -from sqlalchemy.ext.asyncio import create_async_engine # type:ignore +from sqlalchemy.ext.asyncio import create_async_engine # type: ignore logger = logging.getLogger("xcore.services.database.migrations") @@ -35,10 +35,9 @@ def __init__( def _get_config(self): try: - from alembic.config import Config # type:ignore + from alembic.config import Config # type: ignore except ImportError as e: - raise ImportError( - "alembic non installé — pip install alembic") from e + raise ImportError("alembic non installé — pip install alembic") from e cfg = Config() cfg.set_main_option("sqlalchemy.url", self.db_url) @@ -69,7 +68,7 @@ def do_upgrade(sync_conn): command.upgrade(self._get_config(), revision) async def downgrade(self, revision: str = "-1") -> None: - from alembic import command # type:ignore + from alembic import command # type: ignore if "+asyncpg" in self.db_url or "+aiosqlite" in self.db_url: engine = create_async_engine(self.db_url) @@ -89,7 +88,7 @@ def do_downgrade(conn): command.downgrade(self._get_config(), revision) async def revison(self, **kwargs): - from alembic import command # type:ignore + from alembic import command # type: ignore if "+asyncpg" in self.db_url or "+aiosqlite" in self.db_url: engine = create_async_engine(self.db_url) @@ -108,7 +107,7 @@ def do_revision(conn): command.revision(config=self._get_config(), **kwargs) async def status(self, **kwargs): - from alembic import command # type:ignore + from alembic import command # type: ignore if "+asyncpg" in self.db_url or "+aiosqlite" in self.db_url: engine = create_async_engine(self.db_url) diff --git a/xcore/services/scheduler/service.py b/xcore/services/scheduler/service.py index b16978b1..0acdb3a8 100644 --- a/xcore/services/scheduler/service.py +++ b/xcore/services/scheduler/service.py @@ -77,8 +77,7 @@ async def init(self) -> None: jobstores["default"] = RedisJobStore() except ImportError: - logger.warning( - "apscheduler[redis] not install — fallback memory") + logger.warning("apscheduler[redis] not install — fallback memory") self._scheduler = AsyncIOScheduler( jobstores=jobstores, @@ -112,8 +111,7 @@ def _add_job_from_config(self, job_cfg: dict) -> None: **kwargs, ) except Exception as e: - logger.error( - f"Impossible de charger le job '{job_cfg.get('id')}' : {e}") + logger.error(f"Impossible de charger le job '{job_cfg.get('id')}' : {e}") # ── API publique ──────────────────────────────────────────