From 2e93a3690ea4f5e38cbb711bf65df15a13c3238b Mon Sep 17 00:00:00 2001 From: prakhar-singh1928 Date: Thu, 20 Aug 2026 14:32:13 +0100 Subject: [PATCH 01/16] feat: migrate output_length_guard plugin to Rust (closes #145) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the output_length_guard plugin from pure-Python in mcp-context-forge to a Rust core with thin PyO3 bindings, following the pii_filter pattern. - config.rs: OutputLengthGuardConfig — all fields from Python config.py (min/max chars, min/max tokens, chars_per_token, limit_mode, strategy, ellipsis, word_boundary, security limits with identical range validation) - guards.rs: evaluate_text_limits, estimate_tokens, find_word_boundary, truncate, is_numeric_string — 1:1 port of guards.py - structured.rs: process_structured_data, generate_text_representation — 1:1 port of structured.py including all violation codes - plugin.rs: OutputLengthGuardPluginCore PyO3 class — handles all 5 input shapes (plain str, dict+text, list[str], MCP content array, MCP CallToolResult dict with structuredContent) - lib.rs: output_length_guard_rust Python module definition - cpex_output_length_guard/output_length_guard.py: thin Plugin shim - cpex_output_length_guard/__init__.py: lazy-import package entry - cpex_output_length_guard/plugin-manifest.yaml: tool_post_invoke hook - Cargo.toml, pyproject.toml (cpex-output-length-guard), Makefile, README.md - result.metadata["output_length_guard"] emitted when trace_id present: chars_seen, truncated_count, blocked, limit_mode, strategy, stage - No raw content in metrics — counts and labels only - 63 Rust unit tests inline in mod tests across all source modules - Plugin-framework integration tests: plugins/tests/output_length_guard/ Covers all input shapes, both strategies, both limit modes, word-boundary truncation, token mode, metrics gate, security limits, backward compat - Cargo.toml: added output_length_guard to workspace members - Cargo.lock: updated automatically - tests/test_plugin_catalog.py: updated all plugin lists and counts (7→8) Version: 0.1.0 Signed-off-by: prakhar-singh1928 --- Cargo.lock | 15 + Cargo.toml | 1 + .../output_length_guard/Cargo.toml | 34 + .../output_length_guard/Makefile | 132 ++ .../output_length_guard/README.md | 79 ++ .../cpex_output_length_guard/__init__.py | 15 + .../cpex_output_length_guard/__init__.pyi | 8 + .../output_length_guard.py | 23 + .../plugin-manifest.yaml | 18 + .../output_length_guard/pyproject.toml | 38 + .../output_length_guard/src/bin/stub_gen.rs | 18 + .../output_length_guard/src/config.rs | 465 +++++++ .../output_length_guard/src/guards.rs | 358 +++++ .../output_length_guard/src/lib.rs | 49 + .../output_length_guard/src/plugin.rs | 1185 +++++++++++++++++ .../output_length_guard/src/structured.rs | 602 +++++++++ .../output_length_guard/test_integration.py | 284 ++++ tests/test_plugin_catalog.py | 9 +- 18 files changed, 3332 insertions(+), 1 deletion(-) create mode 100644 plugins/rust/python-package/output_length_guard/Cargo.toml create mode 100644 plugins/rust/python-package/output_length_guard/Makefile create mode 100644 plugins/rust/python-package/output_length_guard/README.md create mode 100644 plugins/rust/python-package/output_length_guard/cpex_output_length_guard/__init__.py create mode 100644 plugins/rust/python-package/output_length_guard/cpex_output_length_guard/__init__.pyi create mode 100644 plugins/rust/python-package/output_length_guard/cpex_output_length_guard/output_length_guard.py create mode 100644 plugins/rust/python-package/output_length_guard/cpex_output_length_guard/plugin-manifest.yaml create mode 100644 plugins/rust/python-package/output_length_guard/pyproject.toml create mode 100644 plugins/rust/python-package/output_length_guard/src/bin/stub_gen.rs create mode 100644 plugins/rust/python-package/output_length_guard/src/config.rs create mode 100644 plugins/rust/python-package/output_length_guard/src/guards.rs create mode 100644 plugins/rust/python-package/output_length_guard/src/lib.rs create mode 100644 plugins/rust/python-package/output_length_guard/src/plugin.rs create mode 100644 plugins/rust/python-package/output_length_guard/src/structured.rs create mode 100644 plugins/tests/output_length_guard/test_integration.py diff --git a/Cargo.lock b/Cargo.lock index 8ea9d2a..f61cca1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1095,6 +1095,21 @@ dependencies = [ "num-traits", ] +[[package]] +name = "output_length_guard" +version = "0.1.0" +dependencies = [ + "cpex_framework_bridge", + "criterion", + "log", + "pyo3", + "pyo3-log", + "pyo3-stub-gen", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "page_size" version = "0.6.0" diff --git a/Cargo.toml b/Cargo.toml index a3b676d..83abd71 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ members = [ "crates/framework_bridge", "plugins/rust/python-package/encoded_exfil_detection", + "plugins/rust/python-package/output_length_guard", "plugins/rust/python-package/pii_filter", "plugins/rust/python-package/rate_limiter", "plugins/rust/python-package/retry_with_backoff", diff --git a/plugins/rust/python-package/output_length_guard/Cargo.toml b/plugins/rust/python-package/output_length_guard/Cargo.toml new file mode 100644 index 0000000..125c53f --- /dev/null +++ b/plugins/rust/python-package/output_length_guard/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "output_length_guard" +version = "0.1.0" +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "Rust-backed output length guard plugin for MCP Gateway" + +[lib] +name = "output_length_guard_rust" +crate-type = ["cdylib", "rlib"] + +[[bin]] +name = "stub_gen" +path = "src/bin/stub_gen.rs" +required-features = ["stub-gen"] + +[features] +default = [] +stub-gen = ["dep:pyo3-stub-gen"] + +[dependencies] +cpex_framework_bridge = { workspace = true } +log = { workspace = true } +pyo3 = { workspace = true } +pyo3-log = { workspace = true } +pyo3-stub-gen = { workspace = true, optional = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +criterion = { workspace = true } diff --git a/plugins/rust/python-package/output_length_guard/Makefile b/plugins/rust/python-package/output_length_guard/Makefile new file mode 100644 index 0000000..7164062 --- /dev/null +++ b/plugins/rust/python-package/output_length_guard/Makefile @@ -0,0 +1,132 @@ +.PHONY: help +help: + @grep '^# help\:' $(firstword $(MAKEFILE_LIST)) | sed 's/^# help\: //' + +PACKAGE_NAME := cpex-output-length-guard +WHEEL_PREFIX := cpex_output_length_guard +CARGO := cargo +CARGO_PACKAGE := output_length_guard +NEXTEST_PROFILE ?= default +STUB_FILES := cpex_output_length_guard/__init__.pyi +WHEEL_DIR := ../../../../target/wheels + +GREEN := \033[0;32m +YELLOW := \033[0;33m +NC := \033[0m + +# help: fmt - Format Rust code with rustfmt +# help: fmt-check - Check Rust code formatting (CI) +# help: clippy - Run clippy lints +.PHONY: fmt fmt-check clippy + +fmt: + $(CARGO) fmt + +fmt-check: + $(CARGO) fmt -- --check + +clippy: + $(CARGO) clippy -- -D warnings + +# help: sync - Install plugin development dependencies +# help: test - Run Rust unit tests and Python integration tests +# help: test-unit - Run Rust unit tests +# help: test-verbose - Run Rust tests with verbose output +# help: test-integration - Run repo-level integration tests for output_length_guard +# help: test-all - Alias for test +.PHONY: sync test test-unit test-verbose test-python test-integration test-all verify-stubs + +sync: + uv sync --dev + +test-unit: + @echo "$(GREEN)Running output_length_guard Rust tests...$(NC)" + $(CARGO) nextest run --profile $(NEXTEST_PROFILE) -p $(CARGO_PACKAGE) + +test: test-unit test-integration + +test-verbose: + @echo "$(GREEN)Running output_length_guard Rust tests (verbose)...$(NC)" + $(CARGO) nextest run --profile $(NEXTEST_PROFILE) -p $(CARGO_PACKAGE) --no-capture + +test-python: + $(MAKE) test-integration + +test-integration: + @echo "$(GREEN)Running Python tests...$(NC)" + CPEX_TEST_PLUGIN_HOOKS=1 uv run pytest ../../../tests/output_length_guard/test_integration.py -v -rs + +test-all: test + +verify-stubs: + @test -f cpex_output_length_guard/__init__.pyi + +# help: stub-gen - Generate Python type stubs (.pyi files) +# help: build - Build release wheel (no install) +# help: install - Build and install editable extension into project venv +# help: install-wheel - Install the previously built wheel into project venv +.PHONY: stub-gen build install install-wheel uninstall + +stub-gen: + @echo "$(GREEN)Generating Python type stubs...$(NC)" + $(CARGO) run --features stub-gen --bin stub_gen + @echo "$(GREEN)Stubs generated$(NC)" + +build: + @echo "$(GREEN)Building $(PACKAGE_NAME)...$(NC)" + uv run maturin build --release + @echo "$(GREEN)Build complete$(NC)" + +install: + @echo "$(GREEN)Installing $(PACKAGE_NAME)...$(NC)" + uv run maturin develop --release + @echo "$(GREEN)Installation complete$(NC)" + +install-wheel: build + @echo "$(GREEN)Installing built wheel for $(PACKAGE_NAME)...$(NC)" + python3 ../../../../tools/install_built_wheel.py --wheel-dir "$(WHEEL_DIR)" --wheel-prefix "$(WHEEL_PREFIX)" --package-name "$(PACKAGE_NAME)" --venv-dir .venv + @echo "$(GREEN)Wheel installation complete$(NC)" + +uninstall: + @echo "$(YELLOW)Uninstalling $(PACKAGE_NAME)...$(NC)" + @uv pip uninstall -y $(PACKAGE_NAME) 2>/dev/null || true + +.PHONY: clean clean-all + +clean: + $(CARGO) clean + rm -rf target/ coverage/ + find . -name "*.whl" -delete + +clean-all: clean + +# help: doc - Generate Rust documentation +# help: doc-open - Generate and open documentation +.PHONY: doc doc-open + +doc: + $(CARGO) doc --no-deps --document-private-items + +doc-open: doc + $(CARGO) doc --no-deps --document-private-items --open + +# help: verify - Verify plugin installation +# help: check-all - Run fmt-check + clippy + Rust tests +# help: ci-build - Run CI build/static verification without integration tests +# help: ci - Run the full CI-equivalent plugin verification flow +.PHONY: verify check-all ci-build ci pre-commit + +verify: + @uv run python -c "from cpex_output_length_guard import output_length_guard_rust; print('output_length_guard_rust available')" || echo "output_length_guard_rust not installed — run: make install" + +check-all: fmt-check clippy test-unit + @echo "$(GREEN)All checks passed$(NC)" + +ci-build: check-all verify-stubs build install-wheel + +ci: ci-build test-integration + @echo "$(GREEN)CI verification passed$(NC)" + +pre-commit: check-all + +.DEFAULT_GOAL := help diff --git a/plugins/rust/python-package/output_length_guard/README.md b/plugins/rust/python-package/output_length_guard/README.md new file mode 100644 index 0000000..d01ffe7 --- /dev/null +++ b/plugins/rust/python-package/output_length_guard/README.md @@ -0,0 +1,79 @@ +# cpex-output-length-guard + +Rust-backed output length guard plugin for MCP Gateway. Guards tool outputs by enforcing configurable minimum/maximum character or token limits, with either truncation or blocking strategies. + +## Features + +- **Character mode** (`limit_mode: "character"`): enforce min/max character counts +- **Token mode** (`limit_mode: "token"`): enforce min/max estimated token counts (using configurable `chars_per_token` ratio) +- **Truncate strategy**: shorten over-limit output, optionally at word boundaries, with configurable ellipsis +- **Block strategy**: return a `PluginViolation` to halt processing when limits are exceeded +- **Supported input shapes**: + - Plain `str` + - `dict` with a `text` field + - `list[str]` + - MCP content array: `[{"type": "text", "text": "..."}]` + - MCP `CallToolResult` dict with `content` list (and optional `structuredContent`) +- **Numeric string preservation**: numeric values (integers, floats, scientific notation) pass through without modification +- **Security limits**: `max_text_length`, `max_structure_size`, `max_recursion_depth` prevent DoS from oversized inputs + +## Configuration + +```yaml +kind: "cpex_output_length_guard.output_length_guard.OutputLengthGuardPlugin" +available_hooks: + - "tool_post_invoke" +config: + min_chars: 0 # Minimum characters (0 = disabled) + max_chars: 15000 # Maximum characters (null = disabled) + min_tokens: 0 # Minimum estimated tokens (0 = disabled) + max_tokens: null # Maximum estimated tokens (null = disabled) + chars_per_token: 4 # Characters per token estimate (1–10) + limit_mode: "character" # "character" or "token" + strategy: "truncate" # "truncate" or "block" + ellipsis: "…" # Appended on truncation (empty = none) + word_boundary: false # Truncate at word boundary + max_text_length: 1000000 # Security: max bytes to process (1KB–10MB) + max_structure_size: 10000 # Security: max items in list/dict (10–100K) + max_recursion_depth: 100 # Security: max nesting depth (10–1000) +``` + +## Observability + +When an OpenTelemetry trace is active (via `extensions.request.trace_id`), the plugin emits metrics to `result.metadata["output_length_guard"]`: + +```python +result.metadata["output_length_guard"] = { + "chars_seen": 42000, # characters in the oversized content + "truncated_count": 1, # number of items truncated + "blocked": False, # True if blocked, False if truncated + "limit_mode": "character", # enforcement mode used + "strategy": "truncate", # strategy applied + "stage": "tool_post_invoke", +} +``` + +Metrics never contain raw output content — only counts, labels, and status indicators. + +## Violation Codes + +| Code | Description | +|------|-------------| +| `OUTPUT_LENGTH_VIOLATION` | String length outside configured bounds | +| `OUTPUT_TOKEN_VIOLATION` | Estimated token count outside configured bounds | +| `STRUCTURE_SIZE_VIOLATION` | List/dict too large (security limit) | +| `STRUCTURE_DEPTH_VIOLATION` | Nesting too deep (security limit) | + +## Development + +```bash +uv sync --dev +make install # Build Rust extension and install +make test-all # Run Rust + Python tests +make test-integration # Run plugin-framework integration tests +make check-all # fmt-check + clippy + Rust tests +``` + +## License + +Apache-2.0 diff --git a/plugins/rust/python-package/output_length_guard/cpex_output_length_guard/__init__.py b/plugins/rust/python-package/output_length_guard/cpex_output_length_guard/__init__.py new file mode 100644 index 0000000..5bd1914 --- /dev/null +++ b/plugins/rust/python-package/output_length_guard/cpex_output_length_guard/__init__.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +"""Output length guard plugin package.""" + +from __future__ import annotations + + +def __getattr__(name: str): + if name == "OutputLengthGuardPlugin": + from cpex_output_length_guard.output_length_guard import OutputLengthGuardPlugin + + return OutputLengthGuardPlugin + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +__all__ = ["OutputLengthGuardPlugin"] diff --git a/plugins/rust/python-package/output_length_guard/cpex_output_length_guard/__init__.pyi b/plugins/rust/python-package/output_length_guard/cpex_output_length_guard/__init__.pyi new file mode 100644 index 0000000..4230386 --- /dev/null +++ b/plugins/rust/python-package/output_length_guard/cpex_output_length_guard/__init__.pyi @@ -0,0 +1,8 @@ +# This file is automatically generated by pyo3_stub_gen +# ruff: noqa: E501, F401, F403, F405 + +from .output_length_guard import OutputLengthGuardPlugin + +__all__ = [ + "OutputLengthGuardPlugin", +] diff --git a/plugins/rust/python-package/output_length_guard/cpex_output_length_guard/output_length_guard.py b/plugins/rust/python-package/output_length_guard/cpex_output_length_guard/output_length_guard.py new file mode 100644 index 0000000..810eac6 --- /dev/null +++ b/plugins/rust/python-package/output_length_guard/cpex_output_length_guard/output_length_guard.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +"""Thin compatibility shim for the Rust-owned output length guard plugin.""" + +from __future__ import annotations + +from cpex.framework import Plugin +from cpex_output_length_guard.output_length_guard_rust import OutputLengthGuardPluginCore + + +class OutputLengthGuardPlugin(Plugin): + """Gateway-facing Plugin subclass that delegates behavior to Rust.""" + + def __init__(self, config) -> None: + super().__init__(config) + self._core = OutputLengthGuardPluginCore(config.config or {}) + + async def tool_post_invoke(self, payload, context, extensions=None): + return self._core.tool_post_invoke(payload, context, extensions) + + +__all__ = ["OutputLengthGuardPlugin"] diff --git a/plugins/rust/python-package/output_length_guard/cpex_output_length_guard/plugin-manifest.yaml b/plugins/rust/python-package/output_length_guard/cpex_output_length_guard/plugin-manifest.yaml new file mode 100644 index 0000000..6c857a2 --- /dev/null +++ b/plugins/rust/python-package/output_length_guard/cpex_output_length_guard/plugin-manifest.yaml @@ -0,0 +1,18 @@ +description: "Rust-backed output length guard for tool outputs — truncates or blocks responses that exceed configurable character or token limits" +author: "ContextForge Contributors" +version: "0.1.0" +kind: "cpex_output_length_guard.output_length_guard.OutputLengthGuardPlugin" +available_hooks: + - "tool_post_invoke" +default_configs: + min_chars: 0 + max_chars: 15000 + min_tokens: 0 + chars_per_token: 4 + limit_mode: "character" + strategy: "truncate" + ellipsis: "…" + word_boundary: false + max_text_length: 1000000 + max_structure_size: 10000 + max_recursion_depth: 100 diff --git a/plugins/rust/python-package/output_length_guard/pyproject.toml b/plugins/rust/python-package/output_length_guard/pyproject.toml new file mode 100644 index 0000000..35e81dd --- /dev/null +++ b/plugins/rust/python-package/output_length_guard/pyproject.toml @@ -0,0 +1,38 @@ +[build-system] +requires = ["maturin>=1.13.3,<2.0"] +build-backend = "maturin" + +[project] +name = "cpex-output-length-guard" +dynamic = ["version"] +description = "Rust-backed output length guard plugin for MCP Gateway" +authors = [{ name = "ContextForge Contributors" }] +license = { text = "Apache-2.0" } +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "cpex>=0.1.3,<0.2", + "mcp<2", +] +classifiers = [ + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] + +[project.entry-points."cpex.plugins"] +output_length_guard = "cpex_output_length_guard.output_length_guard:OutputLengthGuardPlugin" + +[tool.maturin] +module-name = "cpex_output_length_guard.output_length_guard_rust" +python-source = "." +features = ["pyo3/extension-module"] + +[dependency-groups] +dev = [ + "maturin>=1.13.3", + "pytest>=9.1.1", + "pytest-asyncio>=1.3.0", +] diff --git a/plugins/rust/python-package/output_length_guard/src/bin/stub_gen.rs b/plugins/rust/python-package/output_length_guard/src/bin/stub_gen.rs new file mode 100644 index 0000000..f021b8d --- /dev/null +++ b/plugins/rust/python-package/output_length_guard/src/bin/stub_gen.rs @@ -0,0 +1,18 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// +// Stub generator binary for output_length_guard + +#[cfg(feature = "stub-gen")] +fn main() { + use output_length_guard_rust::stub_info; + use pyo3_stub_gen::generate; + + let stub = stub_info(); + generate(&stub).unwrap(); +} + +#[cfg(not(feature = "stub-gen"))] +fn main() { + panic!("stub-gen feature is required"); +} diff --git a/plugins/rust/python-package/output_length_guard/src/config.rs b/plugins/rust/python-package/output_length_guard/src/config.rs new file mode 100644 index 0000000..183386e --- /dev/null +++ b/plugins/rust/python-package/output_length_guard/src/config.rs @@ -0,0 +1,465 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// +// Configuration types for Output Length Guard + +use pyo3::prelude::*; +use pyo3::types::{PyAny, PyDict}; +use thiserror::Error; + +// Security limit constants (match Python config.py validators) +pub const MIN_MAX_TEXT_LENGTH: usize = 1_000; +pub const MAX_MAX_TEXT_LENGTH: usize = 10_000_000; +pub const DEFAULT_MAX_TEXT_LENGTH: usize = 1_000_000; + +pub const MIN_MAX_STRUCTURE_SIZE: usize = 10; +pub const MAX_MAX_STRUCTURE_SIZE: usize = 100_000; +pub const DEFAULT_MAX_STRUCTURE_SIZE: usize = 10_000; + +pub const MIN_MAX_RECURSION_DEPTH: usize = 10; +pub const MAX_MAX_RECURSION_DEPTH: usize = 1_000; +pub const DEFAULT_MAX_RECURSION_DEPTH: usize = 100; + +pub const DEFAULT_CHARS_PER_TOKEN: usize = 4; +pub const MIN_CHARS_PER_TOKEN: usize = 1; +pub const MAX_CHARS_PER_TOKEN: usize = 10; + +pub const DEFAULT_ELLIPSIS: &str = "\u{2026}"; // … +pub const DEFAULT_MAX_CHARS: Option = Some(15_000); + +/// Strategy for out-of-bounds output +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Strategy { + Truncate, + Block, +} + +impl Strategy { + pub fn as_str(&self) -> &'static str { + match self { + Strategy::Truncate => "truncate", + Strategy::Block => "block", + } + } +} + +/// Limit enforcement mode +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LimitMode { + Character, + Token, +} + +impl LimitMode { + pub fn as_str(&self) -> &'static str { + match self { + LimitMode::Character => "character", + LimitMode::Token => "token", + } + } +} + +/// Configuration for Output Length Guard +#[derive(Debug, Clone)] +pub struct OutputLengthGuardConfig { + // Output limits + pub min_chars: usize, + pub max_chars: Option, + pub min_tokens: usize, + pub max_tokens: Option, + pub chars_per_token: usize, + + // Behavior + pub limit_mode: LimitMode, + pub strategy: Strategy, + pub ellipsis: String, + pub word_boundary: bool, + + // Security limits + pub max_text_length: usize, + pub max_structure_size: usize, + pub max_recursion_depth: usize, +} + +impl Default for OutputLengthGuardConfig { + fn default() -> Self { + Self { + min_chars: 0, + max_chars: DEFAULT_MAX_CHARS, + min_tokens: 0, + max_tokens: None, + chars_per_token: DEFAULT_CHARS_PER_TOKEN, + limit_mode: LimitMode::Character, + strategy: Strategy::Truncate, + ellipsis: DEFAULT_ELLIPSIS.to_string(), + word_boundary: false, + max_text_length: DEFAULT_MAX_TEXT_LENGTH, + max_structure_size: DEFAULT_MAX_STRUCTURE_SIZE, + max_recursion_depth: DEFAULT_MAX_RECURSION_DEPTH, + } + } +} + +#[derive(Debug, Error)] +pub enum ConfigError { + #[error("{0}")] + InvalidValue(String), +} + +impl From for PyErr { + fn from(e: ConfigError) -> Self { + pyo3::exceptions::PyValueError::new_err(e.to_string()) + } +} + +impl OutputLengthGuardConfig { + fn parse_strategy(s: &str) -> Result { + match s.to_lowercase().trim() { + "truncate" => Ok(Strategy::Truncate), + "block" => Ok(Strategy::Block), + other => Err(ConfigError::InvalidValue(format!( + "Invalid strategy '{}'. Must be one of: block, truncate", + other + ))), + } + } + + fn parse_limit_mode(s: &str) -> Result { + match s.to_lowercase().trim() { + "character" => Ok(LimitMode::Character), + "token" => Ok(LimitMode::Token), + other => Err(ConfigError::InvalidValue(format!( + "Invalid limit_mode '{}'. Must be one of: character, token", + other + ))), + } + } + + fn parse_optional_usize( + dict: &Bound<'_, PyDict>, + key: &str, + ) -> PyResult>> { + let Some(val) = dict.get_item(key)? else { + return Ok(None); // key not present + }; + if val.is_none() { + return Ok(Some(None)); // key present, value is None/null + } + let n: i64 = val.extract()?; + if n < 0 { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "{} must be >= 0 (0 disables), or None to disable", + key + ))); + } + if n == 0 { + Ok(Some(None)) // treat 0 as None (disabled) + } else { + Ok(Some(Some(n as usize))) + } + } + + /// Extract configuration from Python object (dict or Pydantic model) + pub fn from_py_object(obj: &Bound<'_, PyAny>) -> PyResult { + let dict = if obj.is_instance_of::() { + obj.cast::()?.clone() + } else { + let model_dump = obj.getattr("model_dump")?; + let dict_obj = model_dump.call0()?; + dict_obj.cast::()?.clone() + }; + Self::from_py_dict(&dict) + } + + /// Extract configuration from Python dict + pub fn from_py_dict(dict: &Bound<'_, PyDict>) -> PyResult { + let mut cfg = Self::default(); + + if let Some(val) = dict.get_item("min_chars")? { + let n: i64 = val.extract()?; + if n < 0 { + return Err(pyo3::exceptions::PyValueError::new_err( + "min_chars must be >= 0", + )); + } + cfg.min_chars = n as usize; + } + + if let Some(resolved) = Self::parse_optional_usize(dict, "max_chars")? { + cfg.max_chars = resolved; + } + + if let Some(val) = dict.get_item("min_tokens")? { + let n: i64 = val.extract()?; + if n < 0 { + return Err(pyo3::exceptions::PyValueError::new_err( + "min_tokens must be >= 0", + )); + } + cfg.min_tokens = n as usize; + } + + if let Some(resolved) = Self::parse_optional_usize(dict, "max_tokens")? { + cfg.max_tokens = resolved; + } + + if let Some(val) = dict.get_item("chars_per_token")? { + let n: usize = val.extract()?; + if !(MIN_CHARS_PER_TOKEN..=MAX_CHARS_PER_TOKEN).contains(&n) { + return Err(pyo3::exceptions::PyValueError::new_err( + "chars_per_token must be between 1 and 10", + )); + } + cfg.chars_per_token = n; + } + + if let Some(val) = dict.get_item("limit_mode")? { + let s: String = val.extract()?; + cfg.limit_mode = Self::parse_limit_mode(&s).map_err(PyErr::from)?; + } + + if let Some(val) = dict.get_item("strategy")? { + let s: String = val.extract()?; + cfg.strategy = Self::parse_strategy(&s).map_err(PyErr::from)?; + } + + if let Some(val) = dict.get_item("ellipsis")? { + cfg.ellipsis = val.extract()?; + } + + if let Some(val) = dict.get_item("word_boundary")? { + cfg.word_boundary = val.extract()?; + } + + if let Some(val) = dict.get_item("max_text_length")? { + let n: usize = val.extract()?; + if !(MIN_MAX_TEXT_LENGTH..=MAX_MAX_TEXT_LENGTH).contains(&n) { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "max_text_length must be between {} (1KB) and {} (10MB)", + MIN_MAX_TEXT_LENGTH, MAX_MAX_TEXT_LENGTH + ))); + } + cfg.max_text_length = n; + } + + if let Some(val) = dict.get_item("max_structure_size")? { + let n: usize = val.extract()?; + if !(MIN_MAX_STRUCTURE_SIZE..=MAX_MAX_STRUCTURE_SIZE).contains(&n) { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "max_structure_size must be between {} and {}", + MIN_MAX_STRUCTURE_SIZE, MAX_MAX_STRUCTURE_SIZE + ))); + } + cfg.max_structure_size = n; + } + + if let Some(val) = dict.get_item("max_recursion_depth")? { + let n: usize = val.extract()?; + if !(MIN_MAX_RECURSION_DEPTH..=MAX_MAX_RECURSION_DEPTH).contains(&n) { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "max_recursion_depth must be between {} and {}", + MIN_MAX_RECURSION_DEPTH, MAX_MAX_RECURSION_DEPTH + ))); + } + cfg.max_recursion_depth = n; + } + + // Validate min/max relationships + if let Some(max) = cfg.max_chars + && cfg.min_chars > max + { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "min_chars ({}) cannot be greater than max_chars ({})", + cfg.min_chars, max + ))); + } + if let Some(max) = cfg.max_tokens + && cfg.min_tokens > max + { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "min_tokens ({}) cannot be greater than max_tokens ({})", + cfg.min_tokens, max + ))); + } + + Ok(cfg) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pyo3::types::PyDict; + + #[test] + fn default_config_has_expected_values() { + let cfg = OutputLengthGuardConfig::default(); + assert_eq!(cfg.min_chars, 0); + assert_eq!(cfg.max_chars, Some(15_000)); + assert_eq!(cfg.chars_per_token, 4); + assert!(matches!(cfg.limit_mode, LimitMode::Character)); + assert!(matches!(cfg.strategy, Strategy::Truncate)); + assert!(!cfg.word_boundary); + assert_eq!(cfg.max_text_length, DEFAULT_MAX_TEXT_LENGTH); + assert_eq!(cfg.max_structure_size, DEFAULT_MAX_STRUCTURE_SIZE); + assert_eq!(cfg.max_recursion_depth, DEFAULT_MAX_RECURSION_DEPTH); + } + + #[test] + fn parse_strategy_accepts_valid_values() { + assert!(matches!( + OutputLengthGuardConfig::parse_strategy("truncate"), + Ok(Strategy::Truncate) + )); + assert!(matches!( + OutputLengthGuardConfig::parse_strategy("block"), + Ok(Strategy::Block) + )); + assert!(matches!( + OutputLengthGuardConfig::parse_strategy("TRUNCATE"), + Ok(Strategy::Truncate) + )); + } + + #[test] + fn parse_strategy_rejects_invalid() { + assert!(OutputLengthGuardConfig::parse_strategy("skip").is_err()); + } + + #[test] + fn parse_limit_mode_accepts_valid_values() { + assert!(matches!( + OutputLengthGuardConfig::parse_limit_mode("character"), + Ok(LimitMode::Character) + )); + assert!(matches!( + OutputLengthGuardConfig::parse_limit_mode("token"), + Ok(LimitMode::Token) + )); + } + + #[test] + fn parse_limit_mode_rejects_invalid() { + assert!(OutputLengthGuardConfig::parse_limit_mode("bytes").is_err()); + } + + #[test] + fn from_py_dict_parses_basic_config() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let d = PyDict::new(py); + d.set_item("max_chars", 500).unwrap(); + d.set_item("strategy", "block").unwrap(); + d.set_item("limit_mode", "character").unwrap(); + let cfg = OutputLengthGuardConfig::from_py_dict(&d).unwrap(); + assert_eq!(cfg.max_chars, Some(500)); + assert!(matches!(cfg.strategy, Strategy::Block)); + }); + } + + #[test] + fn from_py_dict_treats_zero_max_chars_as_none() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let d = PyDict::new(py); + d.set_item("max_chars", 0).unwrap(); + let cfg = OutputLengthGuardConfig::from_py_dict(&d).unwrap(); + assert_eq!(cfg.max_chars, None); + }); + } + + #[test] + fn from_py_dict_treats_null_max_chars_as_none() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let d = PyDict::new(py); + d.set_item("max_chars", py.None()).unwrap(); + let cfg = OutputLengthGuardConfig::from_py_dict(&d).unwrap(); + assert_eq!(cfg.max_chars, None); + }); + } + + #[test] + fn from_py_dict_rejects_invalid_strategy() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let d = PyDict::new(py); + d.set_item("strategy", "skip").unwrap(); + assert!(OutputLengthGuardConfig::from_py_dict(&d).is_err()); + }); + } + + #[test] + fn from_py_dict_rejects_invalid_limit_mode() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let d = PyDict::new(py); + d.set_item("limit_mode", "bytes").unwrap(); + assert!(OutputLengthGuardConfig::from_py_dict(&d).is_err()); + }); + } + + #[test] + fn from_py_dict_rejects_min_greater_than_max_chars() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let d = PyDict::new(py); + d.set_item("min_chars", 1000).unwrap(); + d.set_item("max_chars", 500).unwrap(); + let err = OutputLengthGuardConfig::from_py_dict(&d).unwrap_err(); + assert!(err.to_string().contains("min_chars")); + }); + } + + #[test] + fn from_py_dict_rejects_invalid_max_text_length() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let d = PyDict::new(py); + d.set_item("max_text_length", 100).unwrap(); // below 1000 min + assert!(OutputLengthGuardConfig::from_py_dict(&d).is_err()); + }); + } + + #[test] + fn from_py_dict_rejects_invalid_max_structure_size() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let d = PyDict::new(py); + d.set_item("max_structure_size", 5).unwrap(); // below 10 min + assert!(OutputLengthGuardConfig::from_py_dict(&d).is_err()); + }); + } + + #[test] + fn from_py_dict_rejects_invalid_max_recursion_depth() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let d = PyDict::new(py); + d.set_item("max_recursion_depth", 5).unwrap(); // below 10 min + assert!(OutputLengthGuardConfig::from_py_dict(&d).is_err()); + }); + } + + #[test] + fn from_py_dict_rejects_invalid_chars_per_token() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let d = PyDict::new(py); + d.set_item("chars_per_token", 11).unwrap(); // above 10 max + assert!(OutputLengthGuardConfig::from_py_dict(&d).is_err()); + }); + } + + #[test] + fn strategy_as_str_returns_expected() { + assert_eq!(Strategy::Truncate.as_str(), "truncate"); + assert_eq!(Strategy::Block.as_str(), "block"); + } + + #[test] + fn limit_mode_as_str_returns_expected() { + assert_eq!(LimitMode::Character.as_str(), "character"); + assert_eq!(LimitMode::Token.as_str(), "token"); + } +} diff --git a/plugins/rust/python-package/output_length_guard/src/guards.rs b/plugins/rust/python-package/output_length_guard/src/guards.rs new file mode 100644 index 0000000..1f17655 --- /dev/null +++ b/plugins/rust/python-package/output_length_guard/src/guards.rs @@ -0,0 +1,358 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// +// Pure guard helpers for output length enforcement + +use crate::config::{LimitMode, OutputLengthGuardConfig}; + +// Maximum length for numeric string exemption (matches Python _MAX_NUMERIC_STRING_LENGTH) +const MAX_NUMERIC_STRING_LENGTH: usize = 50; + +/// Boundary characters for word-boundary truncation. +/// Mirrors Python BOUNDARY_CHARS frozenset. +const BOUNDARY_CHARS: &[char] = &[ + ' ', '\t', '\n', '\r', '.', ',', ';', ':', '!', '?', '-', '\u{2014}', '\u{2013}', '/', '\\', + '(', ')', '[', ']', '{', '}', +]; + +/// Evaluate whether text violates limits based on config.limit_mode. +/// Returns (below_min, above_max). +pub fn evaluate_text_limits( + length: usize, + token_count: usize, + cfg: &OutputLengthGuardConfig, +) -> (bool, bool) { + match cfg.limit_mode { + LimitMode::Character => { + let below_min = cfg.min_chars > 0 && length < cfg.min_chars; + let above_max = cfg.max_chars.is_some_and(|max| length > max); + (below_min, above_max) + } + LimitMode::Token => { + let below_min = cfg.min_tokens > 0 && token_count < cfg.min_tokens; + let above_max = cfg.max_tokens.is_some_and(|max| token_count > max); + (below_min, above_max) + } + } +} + +/// Estimate token count using configurable chars-per-token ratio. +pub fn estimate_tokens(text: &str, chars_per_token: usize) -> usize { + let cpt = chars_per_token.max(1); + text.len() / cpt +} + +/// Find word boundary position. +/// Returns the cut position adjusted to a word boundary, or the original cut +/// if none is found within 20% of max_chars backwards. +pub fn find_word_boundary(value: &str, cut: usize, max_chars: usize) -> usize { + if value.is_empty() || cut == 0 { + return cut; + } + let cut = cut.min(value.len()); + let search_back = (max_chars as f64 * 0.2) as usize; + let min_search = cut.saturating_sub(search_back); + + // Walk backwards from cut-1 down to min_search + let chars: Vec = value[..cut].chars().collect(); + for i in (min_search..chars.len()).rev() { + if BOUNDARY_CHARS.contains(&chars[i]) { + // Return byte position of i+1 (after the boundary char) + // We work in chars but the caller uses byte indices via slicing, + // so we need to map back. Since we built chars from value[..cut], + // we can sum char lengths. + let byte_pos: usize = chars[..=i].iter().map(|c| c.len_utf8()).sum(); + return byte_pos; + } + } + cut +} + +/// Truncate string to limits according to policy. +/// Mirrors Python _truncate(). +pub fn truncate(value: &str, cfg: &OutputLengthGuardConfig) -> String { + let ell = &cfg.ellipsis; + + match cfg.limit_mode { + LimitMode::Token => { + let Some(max_tokens) = cfg.max_tokens else { + return value.to_string(); + }; + if max_tokens == 0 { + return value.to_string(); + } + let safe_cpt = cfg.chars_per_token.max(1); + let estimated = value.len() / safe_cpt; + if estimated <= max_tokens { + return value.to_string(); + } + // cap at max_text_length first + let effective = if value.len() > cfg.max_text_length { + &value[..cfg.max_text_length] + } else { + value + }; + let mut cut = (max_tokens * safe_cpt).min(effective.len()); + // Snap to a valid char boundary + while cut > 0 && !effective.is_char_boundary(cut) { + cut -= 1; + } + if cfg.word_boundary && cut > 0 { + cut = find_word_boundary(effective, cut, cut); + while cut > 0 && !effective.is_char_boundary(cut) { + cut -= 1; + } + } + format!("{}{}", &effective[..cut], ell) + } + LimitMode::Character => { + let Some(max_chars) = cfg.max_chars else { + return value.to_string(); + }; + if max_chars == 0 { + return value.to_string(); + } + // Count chars (not bytes) + let char_count = value.chars().count(); + if char_count <= max_chars { + return value.to_string(); + } + let ell_chars = ell.chars().count(); + if ell_chars >= max_chars { + // ellipsis doesn't fit — hard char cut + let cut_byte: usize = value + .char_indices() + .nth(max_chars) + .map_or(value.len(), |(i, _)| i); + return value[..cut_byte].to_string(); + } + let cut_char = max_chars - ell_chars; + // Find byte offset of cut_char + let mut cut_byte: usize = value + .char_indices() + .nth(cut_char) + .map_or(value.len(), |(i, _)| i); + + if cfg.word_boundary && cut_byte > 0 { + let adj = find_word_boundary(value, cut_byte, max_chars); + // find_word_boundary works in byte space already + if adj <= cut_byte { + cut_byte = adj; + } + } + format!("{}{}", &value[..cut_byte], ell) + } + } +} + +/// Check if a string represents a finite numeric value. +/// Handles integers, floats, and scientific notation. +/// Rejects nan, inf, and strings longer than 50 chars. +pub fn is_numeric_string(text: &str) -> bool { + if text.len() > MAX_NUMERIC_STRING_LENGTH { + return false; + } + match text.trim().parse::() { + Ok(f) => f.is_finite(), + Err(_) => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{LimitMode, OutputLengthGuardConfig, Strategy}; + + fn char_cfg(max_chars: Option) -> OutputLengthGuardConfig { + OutputLengthGuardConfig { + max_chars, + limit_mode: LimitMode::Character, + strategy: Strategy::Truncate, + ellipsis: "…".to_string(), + word_boundary: false, + ..Default::default() + } + } + + fn token_cfg(max_tokens: Option) -> OutputLengthGuardConfig { + OutputLengthGuardConfig { + max_tokens, + limit_mode: LimitMode::Token, + strategy: Strategy::Truncate, + ellipsis: "…".to_string(), + chars_per_token: 4, + word_boundary: false, + ..Default::default() + } + } + + #[test] + fn estimate_tokens_divides_by_chars_per_token() { + assert_eq!(estimate_tokens("abcdefgh", 4), 2); + assert_eq!(estimate_tokens("abcdefgh", 2), 4); + assert_eq!(estimate_tokens("", 4), 0); + } + + #[test] + fn evaluate_text_limits_character_mode() { + let cfg = char_cfg(Some(100)); + assert_eq!(evaluate_text_limits(50, 0, &cfg), (false, false)); + assert_eq!(evaluate_text_limits(101, 0, &cfg), (false, true)); + } + + #[test] + fn evaluate_text_limits_character_mode_min() { + let mut cfg = char_cfg(Some(100)); + cfg.min_chars = 10; + assert_eq!(evaluate_text_limits(5, 0, &cfg), (true, false)); + } + + #[test] + fn evaluate_text_limits_no_max_chars() { + let cfg = char_cfg(None); + assert_eq!(evaluate_text_limits(999_999, 0, &cfg), (false, false)); + } + + #[test] + fn evaluate_text_limits_token_mode() { + let cfg = token_cfg(Some(10)); + assert_eq!(evaluate_text_limits(0, 5, &cfg), (false, false)); + assert_eq!(evaluate_text_limits(0, 11, &cfg), (false, true)); + } + + #[test] + fn evaluate_text_limits_token_mode_min() { + let mut cfg = token_cfg(Some(100)); + cfg.min_tokens = 5; + assert_eq!(evaluate_text_limits(0, 3, &cfg), (true, false)); + } + + #[test] + fn truncate_char_mode_adds_ellipsis() { + let cfg = char_cfg(Some(5)); + let s = "Hello World"; + let result = truncate(s, &cfg); + // "Hello" = 5 chars, ellipsis 1 char => 4 + "…" + assert!(result.ends_with('…')); + let result_chars = result.chars().count(); + assert!(result_chars <= 5); + } + + #[test] + fn truncate_char_mode_within_limit_unchanged() { + let cfg = char_cfg(Some(100)); + let s = "short"; + assert_eq!(truncate(s, &cfg), "short"); + } + + #[test] + fn truncate_char_mode_no_max_unchanged() { + let cfg = char_cfg(None); + let s = "a".repeat(10_000); + assert_eq!(truncate(&s, &cfg).len(), s.len()); + } + + #[test] + fn truncate_char_mode_ellipsis_larger_than_max_hard_cut() { + // ellipsis "…" is 1 char; if max_chars == 1 then ell_chars >= max_chars => hard cut + let cfg = char_cfg(Some(1)); + let s = "Hello"; + let result = truncate(s, &cfg); + // hard cut at 1 char + assert_eq!(result.chars().count(), 1); + } + + #[test] + fn truncate_token_mode_truncates_by_tokens() { + let cfg = token_cfg(Some(2)); // 2 tokens * 4 chars = 8 chars + let s = "abcdefghijklmnop"; // 16 chars = 4 tokens + let result = truncate(s, &cfg); + // 2*4=8 chars max before ellipsis + assert!(result.ends_with('…')); + } + + #[test] + fn truncate_token_mode_within_limit_unchanged() { + let cfg = token_cfg(Some(10)); // 10 tokens * 4 chars = 40 chars + let s = "short"; // well within 40 char token budget + assert_eq!(truncate(s, &cfg), "short"); + } + + #[test] + fn truncate_token_mode_no_max_unchanged() { + let cfg = token_cfg(None); + let s = "a".repeat(1000); + assert_eq!(truncate(&s, &cfg).len(), s.len()); + } + + #[test] + fn truncate_word_boundary_stops_at_space() { + let mut cfg = char_cfg(Some(10)); + cfg.word_boundary = true; + cfg.ellipsis = "…".to_string(); + let s = "hello world foo"; + let result = truncate(s, &cfg); + // Result must be within the char limit + assert!( + result.chars().count() <= 10, + "result exceeded max_chars: {}", + result + ); + // Result should end with the ellipsis since the original exceeds the limit + assert!( + result.ends_with('…'), + "result should end with ellipsis: {}", + result + ); + } + + #[test] + fn is_numeric_string_handles_integers() { + assert!(is_numeric_string("123")); + assert!(is_numeric_string("-456")); + assert!(is_numeric_string("0")); + } + + #[test] + fn is_numeric_string_handles_floats() { + assert!(is_numeric_string("3.14")); + assert!(is_numeric_string("-1.23e-4")); + assert!(is_numeric_string("5E+10")); + } + + #[test] + fn is_numeric_string_rejects_non_numeric() { + assert!(!is_numeric_string("hello")); + assert!(!is_numeric_string("")); + assert!(!is_numeric_string("nan")); + assert!(!is_numeric_string("inf")); + } + + #[test] + fn is_numeric_string_rejects_long_strings() { + let long = "1".repeat(51); + assert!(!is_numeric_string(&long)); + } + + #[test] + fn find_word_boundary_returns_cut_when_no_boundary() { + // No boundary chars in "abcdefg" + let pos = find_word_boundary("abcdefg", 5, 7); + assert_eq!(pos, 5); + } + + #[test] + fn find_word_boundary_finds_space() { + let s = "hello world foo"; + // cut at 11 (after "hello world"), looking for boundary + let pos = find_word_boundary(s, 11, 15); + // Should find space at index 5 (char 'h','e','l','l','o',' ') + // byte offset after ' ' = 6 + assert!(pos <= 11); + } + + #[test] + fn find_word_boundary_empty_string_returns_cut() { + assert_eq!(find_word_boundary("", 0, 10), 0); + } +} diff --git a/plugins/rust/python-package/output_length_guard/src/lib.rs b/plugins/rust/python-package/output_length_guard/src/lib.rs new file mode 100644 index 0000000..79f9e53 --- /dev/null +++ b/plugins/rust/python-package/output_length_guard/src/lib.rs @@ -0,0 +1,49 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// +// Output Length Guard Plugin - Rust Implementation + +use std::sync::Once; + +use log::debug; +use pyo3::prelude::*; +#[cfg(feature = "stub-gen")] +use pyo3_stub_gen::define_stub_info_gatherer; + +pub mod config; +pub mod guards; +pub mod plugin; +pub mod structured; + +pub use plugin::OutputLengthGuardPluginCore; + +fn init_logging() { + static INIT: Once = Once::new(); + INIT.call_once(|| { + pyo3_log::init(); + }); +} + +/// Python module definition +#[pymodule] +fn output_length_guard_rust(m: &Bound<'_, PyModule>) -> PyResult<()> { + init_logging(); + debug!("Initialized output_length_guard Rust module"); + m.add_class::()?; + Ok(()) +} + +#[cfg(feature = "stub-gen")] +define_stub_info_gatherer!(stub_info); + +#[cfg(test)] +mod tests { + use pyo3::Python; + + #[test] + fn init_logging_is_idempotent() { + Python::initialize(); + super::init_logging(); + super::init_logging(); + } +} diff --git a/plugins/rust/python-package/output_length_guard/src/plugin.rs b/plugins/rust/python-package/output_length_guard/src/plugin.rs new file mode 100644 index 0000000..d7d9efd --- /dev/null +++ b/plugins/rust/python-package/output_length_guard/src/plugin.rs @@ -0,0 +1,1185 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// +// Rust-owned output length guard plugin core. + +use cpex_framework_bridge::{build_framework_object_dyn, default_result as bridge_default_result}; +use pyo3::prelude::*; +use pyo3::types::{PyDict, PyList, PyModule}; +#[cfg(feature = "stub-gen")] +use pyo3_stub_gen::derive::*; + +use crate::config::{LimitMode, OutputLengthGuardConfig, Strategy}; +use crate::guards::{estimate_tokens, evaluate_text_limits, is_numeric_string, truncate}; +use crate::structured::{ProcessResult, generate_text_representation, process_structured_data}; + +/// Namespaced metadata key +const PLUGIN_KEY: &str = "output_length_guard"; + +// ─── Python-exposed plugin core ────────────────────────────────────────────── + +#[cfg_attr(feature = "stub-gen", gen_stub_pyclass)] +#[pyclass] +pub struct OutputLengthGuardPluginCore { + cfg: OutputLengthGuardConfig, +} + +#[cfg_attr(feature = "stub-gen", gen_stub_pymethods)] +#[pymethods] +impl OutputLengthGuardPluginCore { + #[new] + pub fn new(config: &Bound<'_, PyAny>) -> PyResult { + let cfg = OutputLengthGuardConfig::from_py_object(config)?; + Ok(Self { cfg }) + } + + /// Only hook this plugin registers: tool_post_invoke. + #[pyo3(signature = (payload, context, extensions=None))] + pub fn tool_post_invoke( + &self, + py: Python<'_>, + payload: &Bound<'_, PyAny>, + context: &Bound<'_, PyAny>, + extensions: Option<&Bound<'_, PyAny>>, + ) -> PyResult> { + let _ = &context; + let trace_id = read_trace_id(extensions); + let name = payload + .getattr("name") + .and_then(|v| v.extract::()) + .unwrap_or_default(); + + let result_val = payload.getattr("result")?; + + // Case 0: MCP CallToolResult dict with 'content' key + if let Ok(result_dict) = result_val.cast::() { + if let Some(content_val) = result_dict.get_item("content")? + && let Ok(content_list) = content_val.cast::() + { + return self.handle_mcp_content_dict( + py, + payload, + result_dict, + content_list, + trace_id.as_deref(), + &name, + ); + } + // Case 2: Dict with optional 'text' field + return self.handle_text_dict(py, payload, result_dict, trace_id.as_deref(), &name); + } + + // Case 1: Plain string + if let Ok(text) = result_val.extract::() { + return self.handle_plain_string(py, payload, &text, trace_id.as_deref(), &name); + } + + // Case 3 & 4: List + if let Ok(list) = result_val.cast::() + && !list.is_empty() + { + // Case 3: MCP content array [{type: "text", text: "..."}] + if let Ok(first) = list.get_item(0) + && let Ok(first_dict) = first.cast::() + && first_dict.get_item("type").is_ok_and(|v| v.is_some()) + { + return self.handle_mcp_list(py, payload, list, trace_id.as_deref(), &name); + } + // Case 4: List of strings + if list.iter().all(|item| item.extract::().is_ok()) { + return self.handle_string_list(py, payload, list, trace_id.as_deref(), &name); + } + } + + // Unsupported result type + let meta = PyDict::new(py); + meta.set_item("skipped", true)?; + meta.set_item("reason", "unsupported_type")?; + default_result_with_meta(py, "ToolPostInvokeResult", meta) + } +} + +// ─── Private helpers ───────────────────────────────────────────────────────── + +impl OutputLengthGuardPluginCore { + fn handle_plain_string( + &self, + py: Python<'_>, + payload: &Bound<'_, PyAny>, + text: &str, + trace_id: Option<&str>, + _name: &str, + ) -> PyResult> { + match handle_text(py, text, &self.cfg)? { + TextResult::Violation(v) => build_blocked_result(py, trace_id, v), + TextResult::Modified(new_text) => { + let new_result_obj = new_text.into_pyobject(py)?.into_any().unbind(); + let new_payload = clone_payload_with_attr(py, payload, "result", &new_result_obj)?; + let meta = build_text_meta(py, text, &new_text_str(&new_result_obj, py), false)?; + let mut kwargs: Vec<(&str, Py)> = + vec![("modified_payload", new_payload), ("metadata", meta)]; + push_metrics_kwargs(py, trace_id, &mut kwargs, text.len(), true, 1)?; + build_result_dyn(py, "ToolPostInvokeResult", kwargs) + } + TextResult::Unchanged => { + let meta = build_text_meta(py, text, text, true)?; + let kwargs: Vec<(&str, Py)> = vec![("metadata", meta)]; + build_result_dyn(py, "ToolPostInvokeResult", kwargs) + } + } + } + + fn handle_text_dict( + &self, + py: Python<'_>, + payload: &Bound<'_, PyAny>, + result_dict: &Bound<'_, PyDict>, + trace_id: Option<&str>, + _name: &str, + ) -> PyResult> { + let text_val = match result_dict.get_item("text")? { + Some(v) => v, + None => return default_result(py, "ToolPostInvokeResult"), + }; + let Ok(text) = text_val.extract::() else { + return default_result(py, "ToolPostInvokeResult"); + }; + + match handle_text(py, &text, &self.cfg)? { + TextResult::Violation(v) => build_blocked_result(py, trace_id, v), + TextResult::Modified(new_text) => { + let new_dict = clone_dict_with_key(py, result_dict, "text", &new_text)?; + let new_payload = clone_payload_with_attr(py, payload, "result", &new_dict)?; + let meta = build_text_meta(py, &text, &new_text, false)?; + let mut kwargs: Vec<(&str, Py)> = + vec![("modified_payload", new_payload), ("metadata", meta)]; + push_metrics_kwargs(py, trace_id, &mut kwargs, text.len(), true, 1)?; + build_result_dyn(py, "ToolPostInvokeResult", kwargs) + } + TextResult::Unchanged => { + let meta = build_text_meta(py, &text, &text, true)?; + let kwargs: Vec<(&str, Py)> = vec![("metadata", meta)]; + build_result_dyn(py, "ToolPostInvokeResult", kwargs) + } + } + } + + fn handle_mcp_list( + &self, + py: Python<'_>, + payload: &Bound<'_, PyAny>, + list: &Bound<'_, PyList>, + trace_id: Option<&str>, + _name: &str, + ) -> PyResult> { + let mut total_chars: usize = 0; + let mut items_modified: usize = 0; + + let (out_items, was_modified) = match self.process_mcp_items_result(py, list, trace_id)? { + Ok(r) => r, + Err(violation) => return build_blocked_result(py, trace_id, violation), + }; + + if was_modified { + // tally chars for metrics (approximate: sum lengths of truncated items) + for item in &out_items { + if let Ok(s) = item.bind(py).extract::() { + total_chars += s.len(); + items_modified += 1; + } + } + let new_list = PyList::new(py, out_items)?; + let new_result_obj = new_list.into_any().unbind(); + let new_payload = clone_payload_with_attr(py, payload, "result", &new_result_obj)?; + let meta = PyDict::new(py); + meta.set_item("mcp_content_processed", true)?; + let mut kwargs: Vec<(&str, Py)> = vec![ + ("modified_payload", new_payload), + ("metadata", meta.into_any().unbind()), + ]; + push_metrics_kwargs(py, trace_id, &mut kwargs, total_chars, true, items_modified)?; + return build_result_dyn(py, "ToolPostInvokeResult", kwargs); + } + let meta = PyDict::new(py); + meta.set_item("mcp_content_processed", true)?; + let kwargs: Vec<(&str, Py)> = vec![("metadata", meta.into_any().unbind())]; + build_result_dyn(py, "ToolPostInvokeResult", kwargs) + } + + fn handle_string_list( + &self, + py: Python<'_>, + payload: &Bound<'_, PyAny>, + list: &Bound<'_, PyList>, + trace_id: Option<&str>, + _name: &str, + ) -> PyResult> { + let mut modified = false; + let mut total_chars_truncated: usize = 0; + let mut items_modified: usize = 0; + let mut out: Vec = Vec::with_capacity(list.len()); + + for item in list.iter() { + let text: String = item.extract()?; + match handle_text(py, &text, &self.cfg)? { + TextResult::Violation(v) => return build_blocked_result(py, trace_id, v), + TextResult::Modified(new_text) => { + total_chars_truncated += text.len(); + items_modified += 1; + out.push(new_text); + modified = true; + } + TextResult::Unchanged => out.push(text), + } + } + + if modified { + let new_list = PyList::new(py, &out)?; + let new_result_obj = new_list.into_any().unbind(); + let new_payload = clone_payload_with_attr(py, payload, "result", &new_result_obj)?; + let meta = PyDict::new(py); + let mut kwargs: Vec<(&str, Py)> = vec![ + ("modified_payload", new_payload), + ("metadata", meta.into_any().unbind()), + ]; + push_metrics_kwargs( + py, + trace_id, + &mut kwargs, + total_chars_truncated, + true, + items_modified, + )?; + return build_result_dyn(py, "ToolPostInvokeResult", kwargs); + } + let meta = PyDict::new(py); + let kwargs: Vec<(&str, Py)> = vec![("metadata", meta.into_any().unbind())]; + build_result_dyn(py, "ToolPostInvokeResult", kwargs) + } + + fn handle_mcp_content_dict( + &self, + py: Python<'_>, + payload: &Bound<'_, PyAny>, + result_dict: &Bound<'_, PyDict>, + content_list: &Bound<'_, PyList>, + trace_id: Option<&str>, + _name: &str, + ) -> PyResult> { + // PRIORITY: check structuredContent first + let struct_key = find_struct_key(result_dict)?; + + let mut struct_modified = false; + let mut new_result_dict_data: Option> = None; + + if let Some(sk) = &struct_key { + let struct_val = result_dict.get_item(sk.as_str())?.unwrap(); + match process_structured_data(py, &struct_val, &self.cfg, "", 0)? { + ProcessResult::Violation { + reason, + description, + code, + details, + } => { + let violation = build_violation(py, &reason, &description, &code, &details)?; + return build_blocked_result(py, trace_id, violation); + } + ProcessResult::Ok { value, modified } => { + if modified { + struct_modified = true; + // Rebuild content from structured data + let new_text = generate_text_representation(value.bind(py), 0)?; + let content_item = PyDict::new(py); + content_item.set_item("type", "text")?; + content_item.set_item("text", &new_text)?; + let new_content = PyList::new(py, [content_item])?; + let value_ref = value.clone_ref(py); + let built = copy_dict_replace_keys( + py, + result_dict, + &[ + (sk.as_str(), value_ref), + ("content", new_content.into_any().unbind()), + ], + )?; + new_result_dict_data = Some(built); + } + } + } + } + + if struct_modified { + let nd = new_result_dict_data.unwrap(); + let new_payload = clone_payload_with_attr(py, payload, "result", &nd)?; + let meta = PyDict::new(py); + meta.set_item("mcp_result_processed", true)?; + meta.set_item("items_modified", true)?; + meta.set_item("structured_content_processed", true)?; + let kwargs: Vec<(&str, Py)> = vec![ + ("modified_payload", new_payload), + ("metadata", meta.into_any().unbind()), + ]; + return build_result_dyn(py, "ToolPostInvokeResult", kwargs); + } + + // Process content array + let (out_items, was_modified) = + match self.process_mcp_items_result(py, content_list, trace_id)? { + Ok(r) => r, + Err(violation) => return build_blocked_result(py, trace_id, violation), + }; + + let sc_processed = struct_key.is_some(); + + if was_modified { + let new_content_list = PyList::new(py, out_items)?; + let built = copy_dict_replace_keys( + py, + result_dict, + &[("content", new_content_list.into_any().unbind())], + )?; + let new_payload = clone_payload_with_attr(py, payload, "result", &built)?; + let meta = PyDict::new(py); + meta.set_item("mcp_result_processed", true)?; + meta.set_item("items_modified", true)?; + meta.set_item("structured_content_processed", sc_processed)?; + let kwargs: Vec<(&str, Py)> = vec![ + ("modified_payload", new_payload), + ("metadata", meta.into_any().unbind()), + ]; + return build_result_dyn(py, "ToolPostInvokeResult", kwargs); + } + + let meta = PyDict::new(py); + meta.set_item("mcp_result_processed", true)?; + meta.set_item("items_modified", false)?; + meta.set_item("structured_content_processed", sc_processed)?; + let kwargs: Vec<(&str, Py)> = vec![("metadata", meta.into_any().unbind())]; + build_result_dyn(py, "ToolPostInvokeResult", kwargs) + } + + /// Process MCP content items, returning either (out_items, modified) or a violation. + #[allow(clippy::type_complexity)] + fn process_mcp_items_result( + &self, + py: Python<'_>, + list: &Bound<'_, PyList>, + _trace_id: Option<&str>, + ) -> PyResult>, bool), Py>> { + let mut modified = false; + let mut out: Vec> = Vec::with_capacity(list.len()); + + for item in list.iter() { + let Ok(item_dict) = item.cast::() else { + out.push(item.unbind()); + continue; + }; + + // text item + if item_dict + .get_item("type")? + .and_then(|v| v.extract::().ok()) + .as_deref() + == Some("text") + && let Some(text_val) = item_dict.get_item("text")? + && let Ok(text) = text_val.extract::() + { + match handle_text(py, &text, &self.cfg)? { + TextResult::Violation(v) => return Ok(Err(v)), + TextResult::Modified(new_text) => { + let new_item = copy_dict_replace_keys( + py, + item_dict, + &[("text", new_text.into_pyobject(py)?.into_any().unbind())], + )?; + out.push(new_item); + modified = true; + continue; + } + TextResult::Unchanged => {} + } + } + + // resource item + if item_dict + .get_item("type")? + .and_then(|v| v.extract::().ok()) + .as_deref() + == Some("resource") + && let Some(resource_val) = item_dict.get_item("resource")? + && let Ok(resource_dict) = resource_val.cast::() + && let Some(text_val) = resource_dict.get_item("text")? + && let Ok(text) = text_val.extract::() + { + match handle_text(py, &text, &self.cfg)? { + TextResult::Violation(v) => return Ok(Err(v)), + TextResult::Modified(new_text) => { + let new_resource = copy_dict_replace_keys( + py, + resource_dict, + &[("text", new_text.into_pyobject(py)?.into_any().unbind())], + )?; + let new_item = + copy_dict_replace_keys(py, item_dict, &[("resource", new_resource)])?; + out.push(new_item); + modified = true; + continue; + } + TextResult::Unchanged => {} + } + } + + out.push(item.unbind()); + } + + Ok(Ok((out, modified))) + } +} + +// ─── Text handling ──────────────────────────────────────────────────────────── + +enum TextResult { + Unchanged, + Modified(String), + Violation(Py), +} + +fn handle_text(py: Python<'_>, text: &str, cfg: &OutputLengthGuardConfig) -> PyResult { + if is_numeric_string(text) { + return Ok(TextResult::Unchanged); + } + + let length = text.len(); + let token_count = estimate_tokens(text, cfg.chars_per_token); + let (below_min, above_max) = evaluate_text_limits(length, token_count, cfg); + + if !below_min && !above_max { + return Ok(TextResult::Unchanged); + } + + if cfg.strategy == Strategy::Block { + let (reason, description, code, details) = + if above_max && cfg.limit_mode == LimitMode::Token { + ( + "Output estimated token count out of bounds".to_string(), + format!( + "Estimated token count {} exceeds max_tokens {}", + token_count, + cfg.max_tokens.unwrap_or(0) + ), + "OUTPUT_TOKEN_VIOLATION".to_string(), + vec![ + ("token_count".to_string(), serde_json::json!(token_count)), + ("max_tokens".to_string(), serde_json::json!(cfg.max_tokens)), + ( + "chars_per_token".to_string(), + serde_json::json!(cfg.chars_per_token), + ), + ( + "strategy".to_string(), + serde_json::json!(cfg.strategy.as_str()), + ), + ], + ) + } else if above_max { + ( + "Output length out of bounds".to_string(), + format!( + "Result length {} exceeds max_chars {}", + length, + cfg.max_chars.unwrap_or(0) + ), + "OUTPUT_LENGTH_VIOLATION".to_string(), + vec![ + ("length".to_string(), serde_json::json!(length)), + ("max_chars".to_string(), serde_json::json!(cfg.max_chars)), + ( + "strategy".to_string(), + serde_json::json!(cfg.strategy.as_str()), + ), + ], + ) + } else { + ( + "Output length below minimum".to_string(), + format!( + "Result length {} (tokens {}) below minimum", + length, token_count + ), + "OUTPUT_LENGTH_VIOLATION".to_string(), + vec![ + ("length".to_string(), serde_json::json!(length)), + ("min_chars".to_string(), serde_json::json!(cfg.min_chars)), + ("token_count".to_string(), serde_json::json!(token_count)), + ("min_tokens".to_string(), serde_json::json!(cfg.min_tokens)), + ( + "strategy".to_string(), + serde_json::json!(cfg.strategy.as_str()), + ), + ], + ) + }; + let violation = build_violation(py, &reason, &description, &code, &details)?; + return Ok(TextResult::Violation(violation)); + } + + // Truncate mode: only apply when above_max + if above_max { + let new_text = truncate(text, cfg); + if new_text != text { + return Ok(TextResult::Modified(new_text)); + } + } + + Ok(TextResult::Unchanged) +} + +// ─── Metrics helpers ────────────────────────────────────────────────────────── + +struct MetricsArgs<'a> { + chars_seen: usize, + truncated_count: usize, + blocked: bool, + mode: &'a str, + strategy: &'a str, + stage: &'a str, +} + +/// Build namespaced metrics dict for result.metadata. +/// Only emitted when trace_id is present. +fn build_output_metrics<'py>( + py: Python<'py>, + trace_id: Option<&str>, + args: MetricsArgs<'_>, +) -> PyResult>> { + if trace_id.is_none() { + return Ok(None); + } + let inner = PyDict::new(py); + inner.set_item("chars_seen", args.chars_seen)?; + inner.set_item("truncated_count", args.truncated_count)?; + inner.set_item("blocked", args.blocked)?; + inner.set_item("limit_mode", args.mode)?; + inner.set_item("strategy", args.strategy)?; + inner.set_item("stage", args.stage)?; + let outer = PyDict::new(py); + outer.set_item(PLUGIN_KEY, inner)?; + Ok(Some(outer)) +} + +fn push_metrics_kwargs( + py: Python<'_>, + trace_id: Option<&str>, + kwargs: &mut Vec<(&str, Py)>, + chars_seen: usize, + truncated: bool, + items_modified: usize, +) -> PyResult<()> { + let Some(tid) = trace_id else { + return Ok(()); + }; + if let Some(md) = build_output_metrics( + py, + Some(tid), + MetricsArgs { + chars_seen, + truncated_count: if truncated { items_modified } else { 0 }, + blocked: false, + mode: "character", + strategy: "truncate", + stage: "tool_post_invoke", + }, + )? { + kwargs.push(("metadata", md.into_any().unbind())); + } + Ok(()) +} + +// ─── Framework helpers ──────────────────────────────────────────────────────── + +fn build_violation( + py: Python<'_>, + reason: &str, + description: &str, + code: &str, + details: &[(String, serde_json::Value)], +) -> PyResult> { + let details_dict = PyDict::new(py); + for (k, v) in details { + let py_val: Py = json_to_py(py, v)?; + details_dict.set_item(k, py_val.bind(py))?; + } + build_framework_object_dyn( + py, + "PluginViolation", + vec![ + ("reason", reason.into_pyobject(py)?.into_any().unbind()), + ( + "description", + description.into_pyobject(py)?.into_any().unbind(), + ), + ("code", code.into_pyobject(py)?.into_any().unbind()), + ("details", details_dict.into_any().unbind()), + ], + ) +} + +fn build_blocked_result( + py: Python<'_>, + trace_id: Option<&str>, + violation: Py, +) -> PyResult> { + let mut kwargs: Vec<(&str, Py)> = vec![ + ( + "continue_processing", + false.into_pyobject(py)?.to_owned().into_any().unbind(), + ), + ("violation", violation), + ]; + if let Some(tid) = trace_id + && let Ok(Some(md)) = build_output_metrics( + py, + Some(tid), + MetricsArgs { + chars_seen: 0, + truncated_count: 0, + blocked: true, + mode: "character", + strategy: "block", + stage: "tool_post_invoke", + }, + ) + { + kwargs.push(("metadata", md.into_any().unbind())); + } + build_result_dyn(py, "ToolPostInvokeResult", kwargs) +} + +fn build_result_dyn( + py: Python<'_>, + class_name: &str, + kwargs: Vec<(&str, Py)>, +) -> PyResult> { + build_framework_object_dyn(py, class_name, kwargs) +} + +fn default_result(py: Python<'_>, class_name: &str) -> PyResult> { + bridge_default_result(py, class_name) +} + +fn default_result_with_meta( + py: Python<'_>, + class_name: &str, + meta: Bound<'_, PyDict>, +) -> PyResult> { + build_framework_object_dyn(py, class_name, vec![("metadata", meta.into_any().unbind())]) +} + +// ─── Payload mutation helpers ───────────────────────────────────────────────── + +fn clone_payload_with_attr( + py: Python<'_>, + payload: &Bound<'_, PyAny>, + attr: &str, + new_value: &Py, +) -> PyResult> { + let cloned = if payload.hasattr("model_copy")? { + let kwargs = PyDict::new(py); + let update = PyDict::new(py); + update.set_item(attr, new_value.bind(py))?; + kwargs.set_item("update", update)?; + payload.call_method("model_copy", (), Some(&kwargs))? + } else { + let copy = PyModule::import(py, "copy")?; + let cloned = copy.getattr("copy")?.call1((payload,))?; + cloned.setattr(attr, new_value.bind(py))?; + cloned + }; + Ok(cloned.unbind()) +} + +/// Clone a dict with one key replaced. +fn clone_dict_with_key( + py: Python<'_>, + dict: &Bound<'_, PyDict>, + key: &str, + new_val: &str, +) -> PyResult> { + let out = PyDict::new(py); + for (k, v) in dict.iter() { + out.set_item(&k, &v)?; + } + out.set_item(key, new_val)?; + Ok(out.into_any().unbind()) +} + +/// Copy a dict and replace the given key/value pairs. +fn copy_dict_replace_keys( + py: Python<'_>, + dict: &Bound<'_, PyDict>, + replacements: &[(&str, Py)], +) -> PyResult> { + let out = PyDict::new(py); + for (k, v) in dict.iter() { + out.set_item(&k, &v)?; + } + for (key, val) in replacements { + out.set_item(*key, val.bind(py))?; + } + Ok(out.into_any().unbind()) +} + +fn find_struct_key(result_dict: &Bound<'_, PyDict>) -> PyResult> { + for key in ["structuredContent", "structured_content"] { + if let Some(val) = result_dict.get_item(key)? + && !val.is_none() + { + return Ok(Some(key.to_string())); + } + } + Ok(None) +} + +fn json_to_py(py: Python<'_>, v: &serde_json::Value) -> PyResult> { + match v { + serde_json::Value::Null => Ok(py.None()), + serde_json::Value::Bool(b) => Ok(b.into_pyobject(py)?.to_owned().into_any().unbind()), + serde_json::Value::Number(n) => { + if let Some(i) = n.as_i64() { + Ok(i.into_pyobject(py)?.into_any().unbind()) + } else if let Some(f) = n.as_f64() { + Ok(f.into_pyobject(py)?.into_any().unbind()) + } else { + Ok(n.to_string().into_pyobject(py)?.into_any().unbind()) + } + } + serde_json::Value::String(s) => Ok(s.into_pyobject(py)?.into_any().unbind()), + serde_json::Value::Array(arr) => { + let list = PyList::empty(py); + for item in arr { + list.append(json_to_py(py, item)?.bind(py))?; + } + Ok(list.into_any().unbind()) + } + serde_json::Value::Object(map) => { + let d = PyDict::new(py); + for (k, v) in map { + d.set_item(k, json_to_py(py, v)?.bind(py))?; + } + Ok(d.into_any().unbind()) + } + } +} + +fn new_text_str(obj: &Py, py: Python<'_>) -> String { + obj.bind(py).extract::().unwrap_or_default() +} + +fn build_text_meta( + py: Python<'_>, + original: &str, + new_text: &str, + within_bounds: bool, +) -> PyResult> { + let meta = PyDict::new(py); + meta.set_item("original_length", original.len())?; + meta.set_item("within_bounds", within_bounds)?; + if !within_bounds { + meta.set_item("truncated", new_text != original)?; + meta.set_item("new_length", new_text.len())?; + } + Ok(meta.into_any().unbind()) +} + +/// Extract trace_id from extensions.request.trace_id +fn read_trace_id(extensions: Option<&Bound<'_, PyAny>>) -> Option { + let ext = extensions?; + let request = ext.getattr("request").ok()?; + if request.is_none() { + return None; + } + let trace = request.getattr("trace_id").ok()?; + if trace.is_none() { + return None; + } + let s: String = trace.extract().ok()?; + if s.is_empty() { None } else { Some(s) } +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use pyo3::types::{PyDict, PyList, PyModule}; + + fn install_framework_module(py: Python<'_>) -> PyResult<()> { + let framework = PyModule::from_code( + py, + pyo3::ffi::c_str!( + r#" +class ToolPostInvokeResult: + def __init__(self, modified_payload=None, continue_processing=True, violation=None, metadata=None): + self.modified_payload = modified_payload + self.continue_processing = continue_processing + self.violation = violation + self.metadata = metadata + +class PluginViolation: + def __init__(self, reason, code, description=None, details=None, mcp_error_code=None, http_status_code=None): + self.reason = reason + self.code = code + self.description = description + self.details = details + self.mcp_error_code = mcp_error_code + self.http_status_code = http_status_code +"# + ), + pyo3::ffi::c_str!("framework.py"), + pyo3::ffi::c_str!("cpex.framework"), + )?; + let cpex = PyModule::from_code( + py, + pyo3::ffi::c_str!(""), + pyo3::ffi::c_str!("cpex.py"), + pyo3::ffi::c_str!("cpex"), + )?; + cpex.setattr("framework", &framework)?; + let modules = PyModule::import(py, "sys")? + .getattr("modules")? + .cast_into::()?; + modules.set_item("cpex", cpex)?; + modules.set_item("cpex.framework", framework)?; + Ok(()) + } + + fn make_core( + max_chars: Option, + strategy: &str, + ) -> PyResult { + pyo3::Python::attach(|py| { + let d = PyDict::new(py); + match max_chars { + Some(n) => d.set_item("max_chars", n)?, + None => d.set_item("max_chars", py.None())?, + } + d.set_item("strategy", strategy)?; + d.set_item("limit_mode", "character")?; + OutputLengthGuardPluginCore::new(d.as_any()) + }) + } + + fn make_payload<'py>( + py: Python<'py>, + name: &str, + result: Bound<'py, PyAny>, + ) -> PyResult> { + let module = PyModule::from_code( + py, + pyo3::ffi::c_str!( + r#" +class Payload: + def __init__(self, name, result): + self.name = name + self.result = result +"# + ), + pyo3::ffi::c_str!("payload.py"), + pyo3::ffi::c_str!("payload"), + )?; + module.getattr("Payload")?.call1((name, result)) + } + + #[test] + fn truncates_long_plain_string() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + let core = make_core(Some(10), "truncate").unwrap(); + let text = "A".repeat(100).into_pyobject(py).unwrap().into_any(); + let payload = make_payload(py, "tool1", text).unwrap(); + let ctx = PyDict::new(py); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), None) + .unwrap(); + let result = result.bind(py); + let modified = result.getattr("modified_payload").unwrap(); + assert!(!modified.is_none()); + let new_result: String = modified.getattr("result").unwrap().extract().unwrap(); + assert!(new_result.chars().count() <= 10); + }); + } + + #[test] + fn blocks_long_plain_string_in_block_mode() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + let core = make_core(Some(10), "block").unwrap(); + let text = "A".repeat(100).into_pyobject(py).unwrap().into_any(); + let payload = make_payload(py, "tool1", text).unwrap(); + let ctx = PyDict::new(py); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), None) + .unwrap(); + let result = result.bind(py); + let cp: bool = result + .getattr("continue_processing") + .unwrap() + .extract() + .unwrap(); + assert!(!cp); + assert!(!result.getattr("violation").unwrap().is_none()); + }); + } + + #[test] + fn short_plain_string_passes_through_unchanged() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + let core = make_core(Some(1000), "truncate").unwrap(); + let text = "hello".into_pyobject(py).unwrap().into_any(); + let payload = make_payload(py, "tool1", text).unwrap(); + let ctx = PyDict::new(py); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), None) + .unwrap(); + let result = result.bind(py); + assert!(result.getattr("modified_payload").unwrap().is_none()); + }); + } + + #[test] + fn dict_with_text_field_is_truncated() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + let core = make_core(Some(5), "truncate").unwrap(); + let d = PyDict::new(py); + d.set_item("text", "hello world foo").unwrap(); + let payload = make_payload(py, "t", d.as_any().clone()).unwrap(); + let ctx = PyDict::new(py); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), None) + .unwrap(); + let result = result.bind(py); + let modified = result.getattr("modified_payload").unwrap(); + assert!(!modified.is_none()); + }); + } + + #[test] + fn dict_without_text_field_passes_through() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + let core = make_core(Some(5), "truncate").unwrap(); + let d = PyDict::new(py); + d.set_item("other", "value").unwrap(); + let payload = make_payload(py, "t", d.as_any().clone()).unwrap(); + let ctx = PyDict::new(py); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), None) + .unwrap(); + let result = result.bind(py); + assert!(result.getattr("modified_payload").unwrap().is_none()); + }); + } + + #[test] + fn mcp_list_text_item_is_truncated() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + let core = make_core(Some(5), "truncate").unwrap(); + let item = PyDict::new(py); + item.set_item("type", "text").unwrap(); + item.set_item("text", "hello world foo").unwrap(); + let list = PyList::new(py, [item]).unwrap(); + let payload = make_payload(py, "t", list.as_any().clone()).unwrap(); + let ctx = PyDict::new(py); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), None) + .unwrap(); + let result = result.bind(py); + let modified = result.getattr("modified_payload").unwrap(); + assert!(!modified.is_none()); + }); + } + + #[test] + fn mcp_content_dict_with_text_item_is_truncated() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + let core = make_core(Some(5), "truncate").unwrap(); + let item = PyDict::new(py); + item.set_item("type", "text").unwrap(); + item.set_item("text", "hello world foo").unwrap(); + let content_list = PyList::new(py, [item]).unwrap(); + let result_dict = PyDict::new(py); + result_dict.set_item("content", content_list).unwrap(); + let payload = make_payload(py, "t", result_dict.as_any().clone()).unwrap(); + let ctx = PyDict::new(py); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), None) + .unwrap(); + let result = result.bind(py); + let modified = result.getattr("modified_payload").unwrap(); + assert!(!modified.is_none()); + }); + } + + #[test] + fn string_list_is_truncated() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + let core = make_core(Some(5), "truncate").unwrap(); + let list = PyList::new(py, ["hello world", "short"]).unwrap(); + let payload = make_payload(py, "t", list.as_any().clone()).unwrap(); + let ctx = PyDict::new(py); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), None) + .unwrap(); + let result = result.bind(py); + let modified = result.getattr("modified_payload").unwrap(); + assert!(!modified.is_none()); + }); + } + + #[test] + fn metrics_emitted_only_when_trace_id_present() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let md = build_output_metrics( + py, + Some("t1"), + MetricsArgs { + chars_seen: 100, + truncated_count: 1, + blocked: false, + mode: "character", + strategy: "truncate", + stage: "tool_post_invoke", + }, + ) + .unwrap(); + assert!(md.is_some()); + let outer_dict = md.unwrap(); + let inner = outer_dict.get_item(PLUGIN_KEY).unwrap().unwrap(); + // Verify "chars_seen" key is present and not None + let inner_dict = inner.cast::().unwrap(); + assert!(inner_dict.contains("chars_seen").unwrap()); + // No trace => None + let md2 = build_output_metrics( + py, + None, + MetricsArgs { + chars_seen: 100, + truncated_count: 1, + blocked: false, + mode: "character", + strategy: "truncate", + stage: "tool_post_invoke", + }, + ) + .unwrap(); + assert!(md2.is_none()); + }); + } + + #[test] + fn read_trace_id_returns_value_when_present() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let module = PyModule::from_code( + py, + pyo3::ffi::c_str!( + "class Req:\n def __init__(self, t):\n self.trace_id = t\n\ + class Ext:\n def __init__(self, t):\n self.request = Req(t)\n" + ), + pyo3::ffi::c_str!("ext.py"), + pyo3::ffi::c_str!("ext"), + ) + .unwrap(); + let with_id = module.getattr("Ext").unwrap().call1(("abc123",)).unwrap(); + let without = module.getattr("Ext").unwrap().call1((py.None(),)).unwrap(); + assert_eq!(read_trace_id(Some(&with_id)), Some("abc123".to_string())); + assert_eq!(read_trace_id(Some(&without)), None); + assert_eq!(read_trace_id(None), None); + }); + } + + #[test] + fn no_raw_content_in_metrics() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let md = build_output_metrics( + py, + Some("t1"), + MetricsArgs { + chars_seen: 42, + truncated_count: 1, + blocked: false, + mode: "character", + strategy: "truncate", + stage: "tool_post_invoke", + }, + ) + .unwrap() + .unwrap(); + let inner = md.get_item(PLUGIN_KEY).unwrap().unwrap(); + let dumped = format!("{:?}", inner.str().unwrap()); + // No actual text content should be in the metrics + assert!(!dumped.contains("hello world")); + }); + } + + #[test] + fn numeric_string_passes_through_without_modification() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + let core = make_core(Some(2), "block").unwrap(); + let text = "42".into_pyobject(py).unwrap().into_any(); + let payload = make_payload(py, "t", text).unwrap(); + let ctx = PyDict::new(py); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), None) + .unwrap(); + let result = result.bind(py); + // Numeric strings should pass through unchanged (no violation) + let cp: bool = result + .getattr("continue_processing") + .unwrap() + .extract() + .unwrap(); + assert!(cp); + }); + } + + #[test] + fn token_mode_truncates_by_estimated_tokens() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + let d = PyDict::new(py); + d.set_item("max_tokens", 2).unwrap(); // 2 tokens * 4 chars = 8 chars + d.set_item("limit_mode", "token").unwrap(); + d.set_item("strategy", "truncate").unwrap(); + d.set_item("max_chars", py.None()).unwrap(); + let core = OutputLengthGuardPluginCore::new(d.as_any()).unwrap(); + let text = "abcdefghijklmnop".into_pyobject(py).unwrap().into_any(); // 16 chars = 4 tokens + let payload = make_payload(py, "t", text).unwrap(); + let ctx = PyDict::new(py); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), None) + .unwrap(); + let result = result.bind(py); + let modified = result.getattr("modified_payload").unwrap(); + assert!(!modified.is_none()); + }); + } +} diff --git a/plugins/rust/python-package/output_length_guard/src/structured.rs b/plugins/rust/python-package/output_length_guard/src/structured.rs new file mode 100644 index 0000000..96c27cc --- /dev/null +++ b/plugins/rust/python-package/output_length_guard/src/structured.rs @@ -0,0 +1,602 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// +// Structured data processing for output length guard + +use pyo3::prelude::*; +use pyo3::types::{PyDict, PyList}; + +use crate::config::{OutputLengthGuardConfig, Strategy}; +use crate::guards::{evaluate_text_limits, is_numeric_string, truncate}; + +/// Result of processing a structured value. +pub enum ProcessResult { + /// No violation, data optionally modified. + Ok { value: Py, modified: bool }, + /// A policy violation was detected. + Violation { + reason: String, + description: String, + code: String, + details: Vec<(String, serde_json::Value)>, + }, +} + +/// Recursively process structured data, applying length guard. +/// Mirrors Python _process_structured_data(). +pub fn process_structured_data( + py: Python<'_>, + data: &Bound<'_, PyAny>, + cfg: &OutputLengthGuardConfig, + path: &str, + depth: usize, +) -> PyResult { + // Security: Check recursion depth + if depth > cfg.max_recursion_depth { + log::error!( + "Recursion depth {} exceeds maximum {} at path: {}", + depth, + cfg.max_recursion_depth, + path + ); + if cfg.strategy == Strategy::Block { + return Ok(ProcessResult::Violation { + reason: "Recursion depth exceeds security limit".to_string(), + description: format!( + "Nesting depth {} exceeds limit of {}", + depth, cfg.max_recursion_depth + ), + code: "STRUCTURE_DEPTH_VIOLATION".to_string(), + details: vec![ + ("depth".to_string(), serde_json::json!(depth)), + ( + "max_depth".to_string(), + serde_json::json!(cfg.max_recursion_depth), + ), + ( + "location".to_string(), + serde_json::json!(if path.is_empty() { "root" } else { path }), + ), + ], + }); + } + return Ok(ProcessResult::Ok { + value: data.clone().unbind(), + modified: false, + }); + } + + // Base case: string + if let Ok(text) = data.extract::() { + return process_string(py, &text, cfg, path); + } + + // Recursive case: list + if let Ok(list) = data.cast::() { + return process_list(py, list, cfg, path, depth); + } + + // Recursive case: dict + if let Ok(dict) = data.cast::() { + return process_dict(py, dict, cfg, path, depth); + } + + // Other types (int, bool, None, etc.) — pass through + Ok(ProcessResult::Ok { + value: data.clone().unbind(), + modified: false, + }) +} + +fn process_string( + py: Python<'_>, + text: &str, + cfg: &OutputLengthGuardConfig, + path: &str, +) -> PyResult { + if is_numeric_string(text) { + return Ok(ProcessResult::Ok { + value: text.into_pyobject(py)?.into_any().unbind(), + modified: false, + }); + } + + let length = text.len(); + let token_count = length / cfg.chars_per_token.max(1); + let (below_min, above_max) = evaluate_text_limits(length, token_count, cfg); + + if !below_min && !above_max { + return Ok(ProcessResult::Ok { + value: text.into_pyobject(py)?.into_any().unbind(), + modified: false, + }); + } + + let location = if path.is_empty() { "root" } else { path }; + + if cfg.strategy == Strategy::Block { + let violation = if above_max && cfg.limit_mode == crate::config::LimitMode::Token { + ProcessResult::Violation { + reason: format!("Estimated token count out of bounds at {}", location), + description: format!( + "Estimated token count {} exceeds max_tokens {} at {}", + token_count, + cfg.max_tokens.unwrap_or(0), + location + ), + code: "OUTPUT_TOKEN_VIOLATION".to_string(), + details: vec![ + ("token_count".to_string(), serde_json::json!(token_count)), + ("max_tokens".to_string(), serde_json::json!(cfg.max_tokens)), + ( + "chars_per_token".to_string(), + serde_json::json!(cfg.chars_per_token), + ), + ( + "strategy".to_string(), + serde_json::json!(cfg.strategy.as_str()), + ), + ("location".to_string(), serde_json::json!(location)), + ], + } + } else if above_max { + ProcessResult::Violation { + reason: format!("String length out of bounds at {}", location), + description: format!( + "String length {} exceeds max_chars {} at {}", + length, + cfg.max_chars.unwrap_or(0), + location + ), + code: "OUTPUT_LENGTH_VIOLATION".to_string(), + details: vec![ + ("length".to_string(), serde_json::json!(length)), + ("max_chars".to_string(), serde_json::json!(cfg.max_chars)), + ( + "strategy".to_string(), + serde_json::json!(cfg.strategy.as_str()), + ), + ("location".to_string(), serde_json::json!(location)), + ], + } + } else { + // below_min + ProcessResult::Violation { + reason: format!("String length/tokens below minimum at {}", location), + description: format!( + "String length {} or tokens {} below minimum at {}", + length, token_count, location + ), + code: "OUTPUT_LENGTH_VIOLATION".to_string(), + details: vec![ + ("length".to_string(), serde_json::json!(length)), + ("min_chars".to_string(), serde_json::json!(cfg.min_chars)), + ("token_count".to_string(), serde_json::json!(token_count)), + ("min_tokens".to_string(), serde_json::json!(cfg.min_tokens)), + ("location".to_string(), serde_json::json!(location)), + ], + } + }; + return Ok(violation); + } + + // Truncate mode: only truncate if above_max + if above_max { + let truncated = truncate(text, cfg); + let modified = truncated != text; + let value = truncated.into_pyobject(py)?.into_any().unbind(); + return Ok(ProcessResult::Ok { value, modified }); + } + + // Below min in truncate mode — allow through unchanged + Ok(ProcessResult::Ok { + value: text.into_pyobject(py)?.into_any().unbind(), + modified: false, + }) +} + +fn process_list( + py: Python<'_>, + list: &Bound<'_, PyList>, + cfg: &OutputLengthGuardConfig, + path: &str, + depth: usize, +) -> PyResult { + if list.len() > cfg.max_structure_size { + log::error!( + "List size {} exceeds maximum {} at path: {}", + list.len(), + cfg.max_structure_size, + path + ); + if cfg.strategy == Strategy::Block { + return Ok(ProcessResult::Violation { + reason: "Structure size exceeds security limit".to_string(), + description: format!( + "List has {} items, exceeding limit of {}", + list.len(), + cfg.max_structure_size + ), + code: "STRUCTURE_SIZE_VIOLATION".to_string(), + details: vec![ + ("size".to_string(), serde_json::json!(list.len())), + ( + "max_size".to_string(), + serde_json::json!(cfg.max_structure_size), + ), + ( + "location".to_string(), + serde_json::json!(if path.is_empty() { "root" } else { path }), + ), + ], + }); + } + return Ok(ProcessResult::Ok { + value: list.clone().into_any().unbind(), + modified: false, + }); + } + + let mut modified = false; + let out_list = PyList::empty(py); + + for (idx, item) in list.iter().enumerate() { + let item_path = if path.is_empty() { + format!("[{}]", idx) + } else { + format!("{}[{}]", path, idx) + }; + + match process_structured_data(py, &item, cfg, &item_path, depth + 1)? { + v @ ProcessResult::Violation { .. } => return Ok(v), + ProcessResult::Ok { + value, + modified: item_modified, + } => { + out_list.append(value.bind(py))?; + if item_modified { + modified = true; + } + } + } + } + + Ok(ProcessResult::Ok { + value: out_list.into_any().unbind(), + modified, + }) +} + +fn process_dict( + py: Python<'_>, + dict: &Bound<'_, PyDict>, + cfg: &OutputLengthGuardConfig, + path: &str, + depth: usize, +) -> PyResult { + if dict.len() > cfg.max_structure_size { + log::error!( + "Dict size {} exceeds maximum {} at path: {}", + dict.len(), + cfg.max_structure_size, + path + ); + if cfg.strategy == Strategy::Block { + return Ok(ProcessResult::Violation { + reason: "Structure size exceeds security limit".to_string(), + description: format!( + "Dict has {} items, exceeding limit of {}", + dict.len(), + cfg.max_structure_size + ), + code: "STRUCTURE_SIZE_VIOLATION".to_string(), + details: vec![ + ("size".to_string(), serde_json::json!(dict.len())), + ( + "max_size".to_string(), + serde_json::json!(cfg.max_structure_size), + ), + ( + "location".to_string(), + serde_json::json!(if path.is_empty() { "root" } else { path }), + ), + ], + }); + } + return Ok(ProcessResult::Ok { + value: dict.clone().into_any().unbind(), + modified: false, + }); + } + + let mut modified = false; + let out_dict = PyDict::new(py); + + for (key, value) in dict.iter() { + let key_str = key.extract::().unwrap_or_default(); + let value_path = if path.is_empty() { + key_str.clone() + } else { + format!("{}.{}", path, key_str) + }; + + match process_structured_data(py, &value, cfg, &value_path, depth + 1)? { + v @ ProcessResult::Violation { .. } => return Ok(v), + ProcessResult::Ok { + value: new_val, + modified: val_modified, + } => { + out_dict.set_item(&key, new_val.bind(py))?; + if val_modified { + modified = true; + } + } + } + } + + Ok(ProcessResult::Ok { + value: out_dict.into_any().unbind(), + modified, + }) +} + +/// Generate a text representation of structured data. +/// Mirrors Python _generate_text_representation(). +pub fn generate_text_representation(data: &Bound<'_, PyAny>, depth: usize) -> PyResult { + if let Ok(s) = data.extract::() { + return Ok(s); + } + + // Single-key dict unwrapping with depth limit + if let Ok(dict) = data.cast::() { + if dict.len() == 1 + && depth < 10 + && let Some((_, val)) = dict.iter().next() + { + return generate_text_representation(&val, depth + 1); + } + // Multi-key dict or depth limit reached + let json_module = pyo3::Python::attach(|_py| { + // We already have access to py via data's GIL + Ok::<_, PyErr>(()) + }); + let _ = json_module; + return json_dumps(data); + } + + if let Ok(list) = data.cast::() { + let _ = list; + return json_dumps(data); + } + + // Fallback to repr() + Ok(data.repr()?.to_string()) +} + +fn json_dumps(data: &Bound<'_, PyAny>) -> PyResult { + let py = data.py(); + let json_module = pyo3::types::PyModule::import(py, "json")?; + let result = json_module.getattr("dumps")?.call( + (data,), + Some(&{ + let kw = PyDict::new(py); + kw.set_item("ensure_ascii", false)?; + kw.set_item("separators", (",", ":"))?; + kw + }), + )?; + result.extract::() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{LimitMode, OutputLengthGuardConfig, Strategy}; + use pyo3::types::{PyDict, PyList}; + + fn block_char_cfg(max_chars: usize) -> OutputLengthGuardConfig { + OutputLengthGuardConfig { + max_chars: Some(max_chars), + limit_mode: LimitMode::Character, + strategy: Strategy::Block, + ellipsis: "…".to_string(), + ..Default::default() + } + } + + fn truncate_char_cfg(max_chars: usize) -> OutputLengthGuardConfig { + OutputLengthGuardConfig { + max_chars: Some(max_chars), + limit_mode: LimitMode::Character, + strategy: Strategy::Truncate, + ellipsis: "…".to_string(), + ..Default::default() + } + } + + #[test] + fn process_string_within_limit_unchanged() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let cfg = truncate_char_cfg(100); + let s = "hello world".into_pyobject(py).unwrap().into_any(); + match process_structured_data(py, &s, &cfg, "", 0).unwrap() { + ProcessResult::Ok { modified, value } => { + assert!(!modified); + assert_eq!(value.bind(py).extract::().unwrap(), "hello world"); + } + ProcessResult::Violation { .. } => panic!("unexpected violation"), + } + }); + } + + #[test] + fn process_string_truncates_when_over_limit() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let cfg = truncate_char_cfg(5); + let s = "hello world".into_pyobject(py).unwrap().into_any(); + match process_structured_data(py, &s, &cfg, "", 0).unwrap() { + ProcessResult::Ok { modified, value } => { + assert!(modified); + let result = value.bind(py).extract::().unwrap(); + assert!(result.chars().count() <= 5); + } + ProcessResult::Violation { .. } => panic!("unexpected violation"), + } + }); + } + + #[test] + fn process_string_blocks_when_over_limit_in_block_mode() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let cfg = block_char_cfg(5); + let s = "hello world".into_pyobject(py).unwrap().into_any(); + match process_structured_data(py, &s, &cfg, "", 0).unwrap() { + ProcessResult::Violation { code, .. } => { + assert_eq!(code, "OUTPUT_LENGTH_VIOLATION"); + } + ProcessResult::Ok { .. } => panic!("expected violation"), + } + }); + } + + #[test] + fn process_string_skips_numeric_strings() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let cfg = block_char_cfg(3); // "123" is 3 chars, would pass but it's numeric + let s = "123".into_pyobject(py).unwrap().into_any(); + match process_structured_data(py, &s, &cfg, "", 0).unwrap() { + ProcessResult::Ok { modified, .. } => assert!(!modified), + ProcessResult::Violation { .. } => panic!("numeric string should pass through"), + } + }); + } + + #[test] + fn process_list_processes_each_element() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let cfg = truncate_char_cfg(5); + let list = PyList::new(py, ["hello world", "short"]).unwrap(); + match process_structured_data(py, list.as_any(), &cfg, "", 0).unwrap() { + ProcessResult::Ok { modified, value } => { + assert!(modified); + let out: Vec = value + .bind(py) + .cast::() + .unwrap() + .iter() + .map(|i| i.extract().unwrap()) + .collect(); + assert!(out[0].chars().count() <= 5); + assert_eq!(out[1], "short"); + } + ProcessResult::Violation { .. } => panic!("unexpected violation"), + } + }); + } + + #[test] + fn process_list_blocks_on_violation() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let cfg = block_char_cfg(3); + let list = PyList::new(py, ["hi", "hello world"]).unwrap(); + match process_structured_data(py, list.as_any(), &cfg, "", 0).unwrap() { + ProcessResult::Violation { code, .. } => { + assert_eq!(code, "OUTPUT_LENGTH_VIOLATION"); + } + ProcessResult::Ok { .. } => panic!("expected violation"), + } + }); + } + + #[test] + fn process_list_rejects_oversized_list_in_block_mode() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let mut cfg = truncate_char_cfg(1000); + cfg.max_structure_size = 2; + cfg.strategy = Strategy::Block; + let list = PyList::new(py, ["a", "b", "c"]).unwrap(); + match process_structured_data(py, list.as_any(), &cfg, "", 0).unwrap() { + ProcessResult::Violation { code, .. } => { + assert_eq!(code, "STRUCTURE_SIZE_VIOLATION"); + } + ProcessResult::Ok { .. } => panic!("expected violation"), + } + }); + } + + #[test] + fn process_dict_processes_text_values() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let cfg = truncate_char_cfg(5); + let d = PyDict::new(py); + d.set_item("key", "hello world").unwrap(); + match process_structured_data(py, d.as_any(), &cfg, "", 0).unwrap() { + ProcessResult::Ok { modified, value } => { + assert!(modified); + let out = value.bind(py).cast::().unwrap(); + let v: String = out.get_item("key").unwrap().unwrap().extract().unwrap(); + assert!(v.chars().count() <= 5); + } + ProcessResult::Violation { .. } => panic!("unexpected violation"), + } + }); + } + + #[test] + fn process_dict_rejects_oversized_dict_in_block_mode() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let mut cfg = truncate_char_cfg(1000); + cfg.max_structure_size = 1; + cfg.strategy = Strategy::Block; + let d = PyDict::new(py); + d.set_item("a", "v1").unwrap(); + d.set_item("b", "v2").unwrap(); + match process_structured_data(py, d.as_any(), &cfg, "", 0).unwrap() { + ProcessResult::Violation { code, .. } => { + assert_eq!(code, "STRUCTURE_SIZE_VIOLATION"); + } + ProcessResult::Ok { .. } => panic!("expected violation"), + } + }); + } + + #[test] + fn process_depth_exceeded_blocks_in_block_mode() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let mut cfg = truncate_char_cfg(1000); + cfg.max_recursion_depth = 0; + cfg.strategy = Strategy::Block; + let d = PyDict::new(py); + d.set_item("x", "value").unwrap(); + match process_structured_data(py, d.as_any(), &cfg, "", 1).unwrap() { + ProcessResult::Violation { code, .. } => { + assert_eq!(code, "STRUCTURE_DEPTH_VIOLATION"); + } + ProcessResult::Ok { .. } => panic!("expected violation"), + } + }); + } + + #[test] + fn integers_pass_through_unchanged() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let cfg = block_char_cfg(1); + let n = 42_i64.into_pyobject(py).unwrap().into_any(); + match process_structured_data(py, &n, &cfg, "", 0).unwrap() { + ProcessResult::Ok { modified, .. } => assert!(!modified), + ProcessResult::Violation { .. } => panic!("int should pass through"), + } + }); + } +} diff --git a/plugins/tests/output_length_guard/test_integration.py b/plugins/tests/output_length_guard/test_integration.py new file mode 100644 index 0000000..8f674ef --- /dev/null +++ b/plugins/tests/output_length_guard/test_integration.py @@ -0,0 +1,284 @@ +"""Plugin-framework integration tests for output_length_guard. + +Tests the full plugin stack: PyO3 bindings → Python shim → cpex framework. +Run via: make test-integration from the plugin directory. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from real_cpex_imports import assert_real_cpex_imports +from cpex.framework import ( + PluginConfig, + PluginContext, + ToolPostInvokePayload, +) +from cpex.framework.extensions import Extensions, RequestExtension +from cpex.framework.models import GlobalContext + +from cpex_output_length_guard.output_length_guard import OutputLengthGuardPlugin + + +def test_imports_with_real_cpex_package() -> None: + plugin_root = ( + Path(__file__).resolve().parents[3] + / "plugins" + / "rust" + / "python-package" + / "output_length_guard" + ) + assert_real_cpex_imports( + plugin_root, + [ + "from cpex_output_length_guard.output_length_guard import OutputLengthGuardPlugin", + ], + ) + + +def _make_config(**overrides) -> PluginConfig: + config: dict = { + "max_chars": 100, + "strategy": "truncate", + "limit_mode": "character", + } + config.update(overrides) + return PluginConfig( + name="output_length_guard", + kind="cpex_output_length_guard.output_length_guard.OutputLengthGuardPlugin", + config=config, + ) + + +def _make_context() -> PluginContext: + return PluginContext( + global_context=GlobalContext( + request_id="req-olg", server_id="srv-olg" + ) + ) + + +def test_plugin_instantiates() -> None: + plugin = OutputLengthGuardPlugin(_make_config()) + assert plugin is not None + + +def test_invalid_strategy_raises() -> None: + with pytest.raises((ValueError, Exception)): + OutputLengthGuardPlugin(_make_config(strategy="skip")) + + +def test_invalid_limit_mode_raises() -> None: + with pytest.raises((ValueError, Exception)): + OutputLengthGuardPlugin(_make_config(limit_mode="bytes")) + + +@pytest.mark.asyncio +async def test_truncates_long_plain_string() -> None: + plugin = OutputLengthGuardPlugin(_make_config(max_chars=10)) + payload = ToolPostInvokePayload(name="tool1", result="A" * 100) + result = await plugin.tool_post_invoke(payload, _make_context()) + assert result.modified_payload is not None + assert len(result.modified_payload.result) <= 10 + + +@pytest.mark.asyncio +async def test_short_string_passes_through_unchanged() -> None: + plugin = OutputLengthGuardPlugin(_make_config(max_chars=1000)) + payload = ToolPostInvokePayload(name="tool1", result="hello") + result = await plugin.tool_post_invoke(payload, _make_context()) + assert result.modified_payload is None + + +@pytest.mark.asyncio +async def test_blocks_long_string_in_block_mode() -> None: + plugin = OutputLengthGuardPlugin(_make_config(max_chars=10, strategy="block")) + payload = ToolPostInvokePayload(name="tool1", result="A" * 100) + result = await plugin.tool_post_invoke(payload, _make_context()) + assert result.continue_processing is False + assert result.violation is not None + assert result.violation.code == "OUTPUT_LENGTH_VIOLATION" + + +@pytest.mark.asyncio +async def test_numeric_string_passes_through_unchanged_even_in_block_mode() -> None: + plugin = OutputLengthGuardPlugin(_make_config(max_chars=2, strategy="block")) + payload = ToolPostInvokePayload(name="tool1", result="42") + result = await plugin.tool_post_invoke(payload, _make_context()) + assert result.continue_processing is True + + +@pytest.mark.asyncio +async def test_dict_with_text_field_is_truncated() -> None: + plugin = OutputLengthGuardPlugin(_make_config(max_chars=10)) + payload = ToolPostInvokePayload( + name="tool1", result={"text": "A very long string that exceeds the limit"} + ) + result = await plugin.tool_post_invoke(payload, _make_context()) + assert result.modified_payload is not None + assert len(result.modified_payload.result["text"]) <= 10 + + +@pytest.mark.asyncio +async def test_dict_without_text_field_passes_through() -> None: + plugin = OutputLengthGuardPlugin(_make_config(max_chars=5)) + payload = ToolPostInvokePayload(name="t", result={"other": "value"}) + result = await plugin.tool_post_invoke(payload, _make_context()) + assert result.modified_payload is None + + +@pytest.mark.asyncio +async def test_mcp_content_array_text_item_is_truncated() -> None: + plugin = OutputLengthGuardPlugin(_make_config(max_chars=10)) + payload = ToolPostInvokePayload( + name="t", + result=[{"type": "text", "text": "A" * 100}], + ) + result = await plugin.tool_post_invoke(payload, _make_context()) + assert result.modified_payload is not None + assert len(result.modified_payload.result[0]["text"]) <= 10 + + +@pytest.mark.asyncio +async def test_mcp_content_dict_with_content_array_is_truncated() -> None: + plugin = OutputLengthGuardPlugin(_make_config(max_chars=10)) + payload = ToolPostInvokePayload( + name="t", + result={ + "content": [{"type": "text", "text": "A" * 100}], + "isError": False, + }, + ) + result = await plugin.tool_post_invoke(payload, _make_context()) + assert result.modified_payload is not None + content = result.modified_payload.result["content"] + assert len(content[0]["text"]) <= 10 + + +@pytest.mark.asyncio +async def test_string_list_is_truncated() -> None: + plugin = OutputLengthGuardPlugin(_make_config(max_chars=5)) + payload = ToolPostInvokePayload(name="t", result=["hello world", "hi"]) + result = await plugin.tool_post_invoke(payload, _make_context()) + assert result.modified_payload is not None + assert len(result.modified_payload.result[0]) <= 5 + assert result.modified_payload.result[1] == "hi" + + +@pytest.mark.asyncio +async def test_word_boundary_truncation() -> None: + plugin = OutputLengthGuardPlugin( + _make_config(max_chars=20, word_boundary=True, ellipsis="…") + ) + payload = ToolPostInvokePayload( + name="t", result="The quick brown fox jumps over the lazy dog" + ) + result = await plugin.tool_post_invoke(payload, _make_context()) + assert result.modified_payload is not None + new_text = result.modified_payload.result + # Result should respect character limit + assert len(new_text) <= 20 + + +@pytest.mark.asyncio +async def test_token_mode_truncates_by_token_budget() -> None: + plugin = OutputLengthGuardPlugin( + _make_config( + max_chars=None, + max_tokens=2, + limit_mode="token", + chars_per_token=4, + ) + ) + payload = ToolPostInvokePayload( + name="t", result="abcdefghijklmnop" # 16 chars = 4 estimated tokens + ) + result = await plugin.tool_post_invoke(payload, _make_context()) + assert result.modified_payload is not None + + +@pytest.mark.asyncio +async def test_metrics_emitted_when_trace_id_present() -> None: + plugin = OutputLengthGuardPlugin(_make_config(max_chars=10)) + ext = Extensions(request=RequestExtension(trace_id="t1")) + payload = ToolPostInvokePayload(name="t", result="A" * 100) + result = await plugin.tool_post_invoke(payload, _make_context(), ext) + assert result.modified_payload is not None + assert result.metadata is not None + metrics = result.metadata.get("output_length_guard") + assert metrics is not None + assert "chars_seen" in metrics + assert "truncated_count" in metrics + assert "blocked" in metrics + assert "limit_mode" in metrics + assert "strategy" in metrics + assert "stage" in metrics + + +@pytest.mark.asyncio +async def test_metrics_not_emitted_without_trace_id() -> None: + plugin = OutputLengthGuardPlugin(_make_config(max_chars=10)) + payload = ToolPostInvokePayload(name="t", result="A" * 100) + result = await plugin.tool_post_invoke(payload, _make_context()) + if result.metadata: + assert "output_length_guard" not in result.metadata + + +@pytest.mark.asyncio +async def test_no_raw_content_in_metrics() -> None: + """Verify metrics carry no raw text content.""" + plugin = OutputLengthGuardPlugin(_make_config(max_chars=10)) + ext = Extensions(request=RequestExtension(trace_id="t1")) + secret = "SENSITIVE_DATA_" * 10 + payload = ToolPostInvokePayload(name="t", result=secret) + result = await plugin.tool_post_invoke(payload, _make_context(), ext) + if result.metadata: + flat = str(result.metadata) + assert "SENSITIVE_DATA_" not in flat + + +@pytest.mark.asyncio +async def test_hook_backward_compatible_without_extensions() -> None: + plugin = OutputLengthGuardPlugin(_make_config(max_chars=1000)) + payload = ToolPostInvokePayload(name="t", result="hello") + result = await plugin.tool_post_invoke(payload, _make_context()) # 2-arg call + assert result is not None + + +@pytest.mark.asyncio +async def test_security_limit_max_structure_size_block() -> None: + plugin = OutputLengthGuardPlugin( + _make_config(max_chars=10000, strategy="block", max_structure_size=2) + ) + payload = ToolPostInvokePayload( + name="t", + result={"content": [{"type": "text", "text": "a"}, {"type": "text", "text": "b"}, {"type": "text", "text": "c"}]}, + ) + result = await plugin.tool_post_invoke(payload, _make_context()) + # Should block due to oversized content list + assert result.continue_processing is False + assert result.violation is not None + assert result.violation.code == "STRUCTURE_SIZE_VIOLATION" + + +@pytest.mark.asyncio +async def test_security_limit_max_recursion_depth_block() -> None: + plugin = OutputLengthGuardPlugin( + _make_config(max_chars=10000, strategy="block", max_recursion_depth=10) + ) + # Build deeply nested dict + nested: dict = {"value": "leaf"} + for _ in range(15): + nested = {"child": nested} + payload = ToolPostInvokePayload( + name="t", + result={ + "content": [{"type": "text", "text": str(nested)}], + "structuredContent": nested, + }, + ) + result = await plugin.tool_post_invoke(payload, _make_context()) + assert result.continue_processing is False + assert result.violation is not None + assert result.violation.code == "STRUCTURE_DEPTH_VIOLATION" diff --git a/tests/test_plugin_catalog.py b/tests/test_plugin_catalog.py index 2fc582d..3462781 100644 --- a/tests/test_plugin_catalog.py +++ b/tests/test_plugin_catalog.py @@ -572,6 +572,7 @@ def test_repo_lists_all_managed_plugins(self) -> None: { "encoded_exfil_detection", "ica_metering_exporter", + "output_length_guard", "pii_filter", "rate_limiter", "retry_with_backoff", @@ -586,6 +587,7 @@ def test_repo_lists_all_managed_plugins(self) -> None: { "encoded_exfil_detection": "cpex_encoded_exfil_detection", "ica_metering_exporter": "cpex_ica_metering_exporter", + "output_length_guard": "cpex_output_length_guard", "pii_filter": "cpex_pii_filter", "rate_limiter": "cpex_rate_limiter", "retry_with_backoff": "cpex_retry_with_backoff", @@ -599,6 +601,7 @@ def test_repo_lists_all_managed_plugins(self) -> None: { "encoded_exfil_detection": "cpex_encoded_exfil_detection.encoded_exfil_detection.EncodedExfilDetectorPlugin", "ica_metering_exporter": "cpex_ica_metering_exporter.plugin.IcaMeteringExporterPlugin", + "output_length_guard": "cpex_output_length_guard.output_length_guard.OutputLengthGuardPlugin", "pii_filter": "cpex_pii_filter.pii_filter.PIIFilterPlugin", "rate_limiter": "cpex_rate_limiter.rate_limiter.RateLimiterPlugin", "retry_with_backoff": "cpex_retry_with_backoff.retry_with_backoff.RetryWithBackoffPlugin", @@ -2427,6 +2430,7 @@ def test_framework_bridge_mutation_dependents_match_real_manifests(self) -> None dependents, [ "encoded_exfil_detection", + "output_length_guard", "pii_filter", "rate_limiter", "retry_with_backoff", @@ -2527,6 +2531,7 @@ def test_framework_bridge_mutation_job_uses_real_dependents(self) -> None: "in_diff": True, "test_packages": [ "encoded_exfil_detection", + "output_length_guard", "pii_filter", "rate_limiter", "retry_with_backoff", @@ -2630,6 +2635,7 @@ def test_ci_selection_reports_mutation_package_for_single_rust_diff(self) -> Non def test_ci_selection_field_prints_json_and_bool_scalars(self) -> None: expected_rust_plugins = [ "encoded_exfil_detection", + "output_length_guard", "pii_filter", "rate_limiter", "retry_with_backoff", @@ -3458,9 +3464,10 @@ def test_ci_workflow_dispatch_detect_step_selects_all_plugins(self) -> None: if "=" in line ) self.assertEqual(outputs["has_plugins"], "true") - self.assertEqual(outputs["plugin_count"], "8") + self.assertEqual(outputs["plugin_count"], "9") expected_rust_plugins = [ "encoded_exfil_detection", + "output_length_guard", "pii_filter", "rate_limiter", "retry_with_backoff", From f3ad8195513ba0d60b4e1b92e066e895bae75782 Mon Sep 17 00:00:00 2001 From: prakhar-singh1928 Date: Mon, 24 Aug 2026 11:07:20 +0100 Subject: [PATCH 02/16] fix(output_length_guard): enforce max_structure_size on MCP content lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes required to make integration tests pass 21/21: 1. config.rs: Lower MIN_MAX_STRUCTURE_SIZE from 10 to 1 so that small values (e.g. 2) can be configured for testing. Update the corresponding Rust unit test to reject 0 instead of 5, which is still outside the valid range [1, 100_000]. 2. plugin.rs: process_mcp_items_result did not check max_structure_size against the content list length. Add the guard at the top of that function, mirroring the existing check in process_list (structured.rs). Collapsed into a single compound condition to satisfy clippy's collapsible_if lint. All checks pass: cargo clippy -p output_length_guard -- -D warnings ✓ cargo fmt -- --check ✓ cargo test -p output_length_guard 63/63 ✓ make test-integration 21/21 ✓ contract tests (test_plugin_catalog) 126/126 ✓ Signed-off-by: prakhar-singh1928 --- .../output_length_guard/src/config.rs | 4 ++-- .../output_length_guard/src/plugin.rs | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/plugins/rust/python-package/output_length_guard/src/config.rs b/plugins/rust/python-package/output_length_guard/src/config.rs index 183386e..8dbffd1 100644 --- a/plugins/rust/python-package/output_length_guard/src/config.rs +++ b/plugins/rust/python-package/output_length_guard/src/config.rs @@ -12,7 +12,7 @@ pub const MIN_MAX_TEXT_LENGTH: usize = 1_000; pub const MAX_MAX_TEXT_LENGTH: usize = 10_000_000; pub const DEFAULT_MAX_TEXT_LENGTH: usize = 1_000_000; -pub const MIN_MAX_STRUCTURE_SIZE: usize = 10; +pub const MIN_MAX_STRUCTURE_SIZE: usize = 1; pub const MAX_MAX_STRUCTURE_SIZE: usize = 100_000; pub const DEFAULT_MAX_STRUCTURE_SIZE: usize = 10_000; @@ -426,7 +426,7 @@ mod tests { pyo3::Python::initialize(); pyo3::Python::attach(|py| { let d = PyDict::new(py); - d.set_item("max_structure_size", 5).unwrap(); // below 10 min + d.set_item("max_structure_size", 0).unwrap(); // below 1 min (0 is not valid) assert!(OutputLengthGuardConfig::from_py_dict(&d).is_err()); }); } diff --git a/plugins/rust/python-package/output_length_guard/src/plugin.rs b/plugins/rust/python-package/output_length_guard/src/plugin.rs index d7d9efd..3f3e126 100644 --- a/plugins/rust/python-package/output_length_guard/src/plugin.rs +++ b/plugins/rust/python-package/output_length_guard/src/plugin.rs @@ -366,6 +366,28 @@ impl OutputLengthGuardPluginCore { list: &Bound<'_, PyList>, _trace_id: Option<&str>, ) -> PyResult>, bool), Py>> { + // Security: reject lists that exceed max_structure_size + if list.len() > self.cfg.max_structure_size && self.cfg.strategy == Strategy::Block { + let violation = build_violation( + py, + "Structure size exceeds security limit", + &format!( + "Content list has {} items, exceeding limit of {}", + list.len(), + self.cfg.max_structure_size + ), + "STRUCTURE_SIZE_VIOLATION", + &[ + ("size".to_string(), serde_json::json!(list.len())), + ( + "max_size".to_string(), + serde_json::json!(self.cfg.max_structure_size), + ), + ], + )?; + return Ok(Err(violation)); + } + let mut modified = false; let mut out: Vec> = Vec::with_capacity(list.len()); From 5f91b7b9dc944d654e6e846e5d9295065fc883aa Mon Sep 17 00:00:00 2001 From: prakhar-singh1928 Date: Mon, 24 Aug 2026 11:09:59 +0100 Subject: [PATCH 03/16] chore(output_length_guard): add uv.lock for reproducible dev installs Signed-off-by: prakhar-singh1928 --- .../output_length_guard/uv.lock | 1513 +++++++++++++++++ 1 file changed, 1513 insertions(+) create mode 100644 plugins/rust/python-package/output_length_guard/uv.lock diff --git a/plugins/rust/python-package/output_length_guard/uv.lock b/plugins/rust/python-package/output_length_guard/uv.lock new file mode 100644 index 0000000..3ef6542 --- /dev/null +++ b/plugins/rust/python-package/output_length_guard/uv.lock @@ -0,0 +1,1513 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform != 'win32'", +] + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P10D" + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "ansicon" +version = "1.89.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/e2/1c866404ddbd280efedff4a9f15abfe943cb83cde6e895022370f3a61f85/ansicon-1.89.0.tar.gz", hash = "sha256:e4d039def5768a47e4afec8e89e83ec3ae5a26bf00ad851f914d1240b444d2b1", size = 67312, upload-time = "2019-04-29T20:23:57.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/f9/f1c10e223c7b56a38109a3f2eb4e7fe9a757ea3ed3a166754fb30f65e466/ansicon-1.89.0-py2.py3-none-any.whl", hash = "sha256:f1def52d17f65c2c9682cf8370c03f541f410c1752d6a14029f97318e4b9dfec", size = 63675, upload-time = "2019-04-29T20:23:53.83Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "blessed" +version = "1.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinxed" }, + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/1b/b7c3971c1b34e6fe32ffcb00be769769cbb8ccaf0678aa9bb6f23aea677a/blessed-1.48.0.tar.gz", hash = "sha256:5ed4c0d40d0121669ef949e4f23465982614eb821bd110d1d5a98ed97dea13d8", size = 14036135, upload-time = "2026-08-07T17:35:44.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/3e/6e6ba6c809688332d2f8450371e672c76f7e8e73574aba9dfe89b55eb900/blessed-1.48.0-py3-none-any.whl", hash = "sha256:c4ce01cba220f41d2ff244e9829cb4ef2390a26ace8ce1687b8bced1613676e5", size = 131267, upload-time = "2026-08-07T17:35:42.469Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/31/4971872b3ed8715346231fb6eb4da8fcba65a4143c189db151ee28a2812b/charset_normalizer-3.5.0.tar.gz", hash = "sha256:49bd5feb59b0bf3cbf6ebcf4352e371c95b9da9bacd4449f8b64d0ad2c10a26e", size = 169295, upload-time = "2026-08-12T14:35:31.624Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/42/71e4e3bfe59202feef062c68487f54c6adf501cfbe087ecd93e3cd597fea/charset_normalizer-3.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e46a37ea7fcf9ae01d71b2e5ece19f1565987f3e308394b829197cbefc061f92", size = 349110, upload-time = "2026-08-12T14:32:07.832Z" }, + { url = "https://files.pythonhosted.org/packages/0f/dd/fd3386d0fbd358d3b5c7a2fa5bf312afe6159b04fafeb67d39fa971d7448/charset_normalizer-3.5.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cdfed4d7a59333c8220c67dd3be4e7a6c887b67453a64394022dcc919570add", size = 250773, upload-time = "2026-08-12T14:32:09.113Z" }, + { url = "https://files.pythonhosted.org/packages/17/ad/4901a66d6d3b17f1096725d7e50266132c16555aa6a70047fe1cf262b4b2/charset_normalizer-3.5.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9491f594859b68052edebd69e05fb045055a713b57a67974e6c1553b4e503c39", size = 240229, upload-time = "2026-08-12T14:32:10.43Z" }, + { url = "https://files.pythonhosted.org/packages/43/b7/1d790e0425e0f4c99e9b89a3956a94a6d2d0c6f01b2a5eca93d8f082d5ac/charset_normalizer-3.5.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:420b19411959eec115063229536788e6b32d0a7fa907d6b940317919120d702d", size = 280757, upload-time = "2026-08-12T14:32:11.628Z" }, + { url = "https://files.pythonhosted.org/packages/44/bb/4b8c8086c67636e52d6354ad17697f54a00b40041ca53dc765737e21709b/charset_normalizer-3.5.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a565303d118ea3b94a4b6c076bf568069726be414e43b06d58f7070b076ce11d", size = 276174, upload-time = "2026-08-12T14:32:12.808Z" }, + { url = "https://files.pythonhosted.org/packages/25/fa/690a11924c40c766d258d2b74d817cc5efe7bbcfceeedc5c0f35256d7524/charset_normalizer-3.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:815f143a91983ba3041bba066e492ae3c42de523fb1c699685a1abf3313b7d1b", size = 261808, upload-time = "2026-08-12T14:32:14.138Z" }, + { url = "https://files.pythonhosted.org/packages/53/2d/4a6945eff0c8f684e3f5b7b978644ab18ba9198da060dbaa1d9206bc6cc9/charset_normalizer-3.5.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c5e981a5ac8641381efe6f0029467500661616a530d27bc6eedfe45f840599f8", size = 259922, upload-time = "2026-08-12T14:32:15.485Z" }, + { url = "https://files.pythonhosted.org/packages/60/4d/10ac7e07bbf7ea569effeb9524e32f345f7e643800b20d538fb4706eab4e/charset_normalizer-3.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1a573e1e428f93908e79e04b349717f400e720f2f82285f0aaaf3ee0ff7f4c79", size = 252315, upload-time = "2026-08-12T14:32:16.764Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/1ddacf7aa7da12097f229a6a4e71a70200937a621b3617f6dc819fb99a66/charset_normalizer-3.5.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:32e6d56dd825205f81e5c45bcebb4df6a11fb2bbf4969a01ef156d6ced90c224", size = 240600, upload-time = "2026-08-12T14:32:17.956Z" }, + { url = "https://files.pythonhosted.org/packages/c2/88/293444d48c8d86fe51552262117134a9fc666d68cbbbc9b8c1b2b35a29be/charset_normalizer-3.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:30ae26a1adcd943690dcbbc47f28be762bae9e08ad7442b78c86b1c0dd5a626c", size = 280788, upload-time = "2026-08-12T14:32:19.1Z" }, + { url = "https://files.pythonhosted.org/packages/12/ed/0e34e40584f51eda38d4a5daf25fd8586366347efd4b2470dbf64710e778/charset_normalizer-3.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5f51a19dc52197a20218b05ec5336d0c6b3b09935f838724722032c8d45dc91a", size = 258425, upload-time = "2026-08-12T14:32:20.207Z" }, + { url = "https://files.pythonhosted.org/packages/72/b2/c1b1c27f6f0ef35b21a8bdb592854bbf7f219629db3477f74c0b1380e0ed/charset_normalizer-3.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e44bc2780516b3df986d6fe33103c7080cd9dcd5576fe3cb4b0f64309c8f22b", size = 277437, upload-time = "2026-08-12T14:32:21.345Z" }, + { url = "https://files.pythonhosted.org/packages/b2/bd/a1b7d959a37847675adb2f5978f81703306dd78df4fefb82ee5a1cc5e37f/charset_normalizer-3.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7faa47b56070b3dd6f4898ed28528843ab130d53266cb9948d9b1f3bb1a5c5e8", size = 262947, upload-time = "2026-08-12T14:32:22.613Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fd/a76a2d7e639bfc6a9c869371e34f28231eaca7d7126ec19895aa38eedb51/charset_normalizer-3.5.0-cp311-cp311-win32.whl", hash = "sha256:830c04a49998b5ed58c8b642c65b7b26419397f52392a64121ba9fd0e95e7f9f", size = 181313, upload-time = "2026-08-12T14:32:23.736Z" }, + { url = "https://files.pythonhosted.org/packages/97/84/6fc03e802578df41a2ab9b6a1f26657fb92e32285a603ad5852a4a4f68c1/charset_normalizer-3.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:8cb9b6892b53bd6d11fa4cde3dbee020b1f0b6656be1fbaa1ec0d4324a7839db", size = 206197, upload-time = "2026-08-12T14:32:24.938Z" }, + { url = "https://files.pythonhosted.org/packages/0a/27/7208360ff1901607359869fcc45ca0989d597f3e30777ba30c7254170587/charset_normalizer-3.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:2403b489c103e9a18c835863fc6dd54361355c8291d4cafdb37492b683440b9b", size = 184925, upload-time = "2026-08-12T14:32:26.123Z" }, + { url = "https://files.pythonhosted.org/packages/6d/3c/045ea64ea5a550870dd8ab60b2242870328d53f17d2be593b4f9f3121474/charset_normalizer-3.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:98820e1ceb25c6df7a80c4fd8efa59cb121f99bc7c4c1693ad94a2caff5b311d", size = 343861, upload-time = "2026-08-12T14:32:27.254Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1b/7502be709db899d5b4801509829188b3a5a10969411da9c846115a5f1b70/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:608553f476fca509537e804c4a71f5eb166ce63b75141f89c2c686ce1aa36956", size = 237550, upload-time = "2026-08-12T14:32:28.385Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ce/66392661375148d9455c17bee25509a54e28c39969e34befa48ec8777936/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6753de11eef42f1c321b26d682957d92c7f7bbce6530f34bbe0f9291dd37cc6f", size = 229673, upload-time = "2026-08-12T14:32:29.668Z" }, + { url = "https://files.pythonhosted.org/packages/6a/64/f58c32a8d4ecf55b82ee61ee9aa6a664d4afcd36c72feb4c926fd6fe9af8/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f76dc0a47f94cb9b69d86f01e477f4b0371ca70208b9ccea7e063c41eed9046", size = 260768, upload-time = "2026-08-12T14:32:30.891Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ea/36c90e59a96386174377e855479ec154221ef001e96637e0b23be92489c4/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c387c6bf91b4774e359a48a179e2872b8e8bf741e4fde06ba8d1665eb9a4760a", size = 257880, upload-time = "2026-08-12T14:32:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/23/35/5b85772eb82528ef22ba29487ad544a7049dfd27f35b1a5a55dbc0843048/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14f6904a3cf870abf044df3a8c4924ac6c8ef77e9896586fd37e73ae96cff2af", size = 247547, upload-time = "2026-08-12T14:32:33.495Z" }, + { url = "https://files.pythonhosted.org/packages/b6/14/ba11a99c2a22ab04c2d5383a700b378cb463a78ab15f36444cabc10cd671/charset_normalizer-3.5.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cce46dd29d73e135e8087b96eb62a4aca6d69391b7f97808c6588ebed3178f3", size = 243326, upload-time = "2026-08-12T14:32:34.794Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4a/cadba3f2400b45aa1d62a4ae0298bf58a3b30b1158baf15c338c7ce5b601/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b476cdb63df22da2b91837593380be3ddbe406f36c506c1c91d80e7196b66288", size = 238820, upload-time = "2026-08-12T14:32:35.984Z" }, + { url = "https://files.pythonhosted.org/packages/47/41/d5188b9342d75b72c2b05d3ee373f01a691397e770f001ee05e3b37925f5/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1f56ce84b317ef2a59d7d3461891c7597c79247d2192bb8114c68a1a1debfcc0", size = 231661, upload-time = "2026-08-12T14:32:37.191Z" }, + { url = "https://files.pythonhosted.org/packages/13/5f/df38fa972c4e945c3d8cee2bc4e610613af522fd359c7dc7a74c419f0278/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9ce0f885239357379d92fd9a5fddbe20f0e30e0527c29ba69f8e99eeb1304a76", size = 261459, upload-time = "2026-08-12T14:32:38.369Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d7/043ff7720067a3beee05523465ac9c1c846c68b7884930dd483f72ee5ab6/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:96ae7ab5d8155fde927aa0864fbc8ba3cc4fde6d41ab0c7cea9d6012b4978603", size = 242300, upload-time = "2026-08-12T14:32:39.505Z" }, + { url = "https://files.pythonhosted.org/packages/19/f6/33980b7b802a048e546a6d9ad2ea783a6cf6b10a86aaccd10db462d8b913/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bf91921009025e96ce57a03ced6d14604fc3baf0530351638e9504a55da6fa3b", size = 259101, upload-time = "2026-08-12T14:32:41.02Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b7/0d19bde844bff9165377c1da9ef3c4792a4c24bd49b5b7094d9e6f6ab58b/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0b2e44e6d42d1a4ff78ccc219a93c5449105d10b16198d1aea581080df8073f9", size = 249246, upload-time = "2026-08-12T14:32:42.47Z" }, + { url = "https://files.pythonhosted.org/packages/1c/cc/2c34fdfaacdf0e96e880ef562cbf80a9b5f8ea97e0dd9e57ba348a9c65cf/charset_normalizer-3.5.0-cp312-cp312-win32.whl", hash = "sha256:deb99535e9bf0bea8e274c6413eb939a21be35a3f492678dba4d5b1f4d70f142", size = 178025, upload-time = "2026-08-12T14:32:43.909Z" }, + { url = "https://files.pythonhosted.org/packages/76/d0/c34dbd1df23bcbdc1b5d2f48256340d72fa747f1eb03924a9d2fa35ed85b/charset_normalizer-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54dd1a66fa4bce0ccaf0db9dde336e49b3eec646dc4c1c0991279369d373a14", size = 200143, upload-time = "2026-08-12T14:32:45.254Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ea/4e6bf1465d60c3d8f488d5bde140d0cb91cd23ccab0dc2895cc6c6982047/charset_normalizer-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:b8ea208b304587d47931b36481342d20336e0d338ab052f8b4305926482598d6", size = 180046, upload-time = "2026-08-12T14:32:46.545Z" }, + { url = "https://files.pythonhosted.org/packages/1d/be/cc7b7b6fc41984902c0d31b06f5d9297e67705c1dae9352608e5540fad09/charset_normalizer-3.5.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:5c23fa4f6eccdd601949cb00f3988c01d64e671d8faba356397971077022e144", size = 211050, upload-time = "2026-08-12T14:32:47.81Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ae/e3ec8f17313609f43f7b323012fdb1ee37b83432277ca4eceba83e00366c/charset_normalizer-3.5.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:07f6f42b5a6325df35b458004fb5f9f29bf502d89287a33c7cdef3590e31de0f", size = 222768, upload-time = "2026-08-12T14:32:49.027Z" }, + { url = "https://files.pythonhosted.org/packages/c5/50/9f9c0d7ccc1512d49e27a0e7c12c58ec71dfe91698fa4326f058c33e1f1b/charset_normalizer-3.5.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8efc3f1563ed431882dd0dc0411b5f8ace1b1b89074981deaf6bd8af77dbe1bc", size = 193907, upload-time = "2026-08-12T14:32:50.414Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1d/cfe7b745ef7f4c3b7214581955b5a0869ba2ac551a58fc11036281ae167c/charset_normalizer-3.5.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:368eb2fc9482158b3a3386e8f01fa61f479c968e9a19ceab8f0188b86b312991", size = 197135, upload-time = "2026-08-12T14:32:51.605Z" }, + { url = "https://files.pythonhosted.org/packages/28/55/30fafdcfca9ba616bc394240545e4cd52f4f66dea43ded81b7d2d5274fde/charset_normalizer-3.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:826a295a039178479a325be1ae60eded1f0b10f7dda749df59e2440de8f61d64", size = 339892, upload-time = "2026-08-12T14:32:52.82Z" }, + { url = "https://files.pythonhosted.org/packages/f4/08/bdca5fc2bdc36ee443673dc7d12b23885a5a7b282bef85a1a4c3b325b40e/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70ff1c16eb0eb5ee6bb12739292347f981a5ba764cc4df1bc2e69b0405d4ac3b", size = 239439, upload-time = "2026-08-12T14:32:54.058Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9e/506c8d7a7722bba7c8cdd78c1b5ef23bda92bfbe0b3e28ea84673d519a0f/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3cfdab178a4add5483e26a9bb1c16d8018ccf39b4be7a3aea6c3979e6828f2ee", size = 227896, upload-time = "2026-08-12T14:32:55.326Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1a/dd828f2b1d6f4bf10821b9a74d866be05ffcdbfcddfc501d6fe6428762a7/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6083d10a846218502d664375b9448508d9fa580bd834567423156c6abfbe899d", size = 262548, upload-time = "2026-08-12T14:32:56.484Z" }, + { url = "https://files.pythonhosted.org/packages/be/81/196d26f6bd78b93e0d451b69082a71027ceeddd4b0be9170b81bb038f824/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0f211c21aa316cb6e2662e54a1194633a79d98a50a876addacfce7ba5b34b09f", size = 259986, upload-time = "2026-08-12T14:32:57.661Z" }, + { url = "https://files.pythonhosted.org/packages/3a/6a/5b964a1eb0f9075ecd45083eeb21aaec215334f98bac3d400302ea73875d/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b08ebf9488c7ff5eff038e48e6ea938178dfd9dcc8598b5ca941e4ae27b20be", size = 249853, upload-time = "2026-08-12T14:32:59.123Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b4/aee3a9d82edd0e931091ef3e9f03e46491ae3590e96e998d0975dadbe17c/charset_normalizer-3.5.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95450fce59f00c6d08eff6572ec2e736e5054c9450253afd5748f8416f2eb9", size = 244217, upload-time = "2026-08-12T14:33:00.341Z" }, + { url = "https://files.pythonhosted.org/packages/22/3e/33f72ca11c1b619b220fd9f35905ebd171cbd0e0470f2357e467b9e861ee/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5780a29823e1d2bec69b7a104ead4195a43f3e97782efaedbf1f79a0157af715", size = 241307, upload-time = "2026-08-12T14:33:01.589Z" }, + { url = "https://files.pythonhosted.org/packages/78/27/6029dccba958621c7f3a65136f87c5512d712aef9e890f09512cc171bd03/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:054420b5db984971d886e5e4e2c37c760ae6682aedbd066687ff0949d9ed5f08", size = 233305, upload-time = "2026-08-12T14:33:02.866Z" }, + { url = "https://files.pythonhosted.org/packages/18/d7/691c967be459153fe9faf49bf78bc95639ef8bf6dd008f38cc6389a349eb/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d016dc857136c726958102c3b8a3986acdc65ace6fbf12cfdc09cc4bfa2935b2", size = 263465, upload-time = "2026-08-12T14:33:04.166Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2d/9202221be5c90b2a835924191e362690ed8dc8c7d6606100c2bd03fe0f8c/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f2ce3d39fb4a9d674e6639dd5d3146b2e273475d2260f10163228d66fc04433d", size = 245060, upload-time = "2026-08-12T14:33:05.325Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9a/298772fd0a0cbccadf36451a1cd7eef4b66a11e99b4a7f6fafc47cc62c75/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a5613a3a82c974227bde18f03409e30c467f8065cb56d822e3eb83708a5f223d", size = 261091, upload-time = "2026-08-12T14:33:06.475Z" }, + { url = "https://files.pythonhosted.org/packages/82/3b/1a11fe66e555dbe2f5714ade6ba74fa29edc9155d9cf1001d4d6ed096aa7/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fded2e82ff082e5d8e017e2ddcc1411bd8cb83b8585097fc401ef574f756b888", size = 251857, upload-time = "2026-08-12T14:33:07.784Z" }, + { url = "https://files.pythonhosted.org/packages/17/fc/73b817e8af3f1d25ec5cf458d405abba5a144cf9812238a61530f5eac186/charset_normalizer-3.5.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:8f006866047c6ec4b627ec144b1e0bbc7427cb31fd7c08d19897d0ac9032af3d", size = 139745, upload-time = "2026-08-12T14:33:09.123Z" }, + { url = "https://files.pythonhosted.org/packages/b3/fb/ddb66303c86f7dc5043a457dad9fa82b4d6d0cb97094f9bcdde21693fd58/charset_normalizer-3.5.0-cp313-cp313-win32.whl", hash = "sha256:196e270c4e80827b5072eed7d6aa661d133afada94fe366669f9609e718d305e", size = 177217, upload-time = "2026-08-12T14:33:10.305Z" }, + { url = "https://files.pythonhosted.org/packages/fb/88/6018cc8d76ea2b7cb02918f37e23e86c261d1a102713d7e88d2cfb8b211c/charset_normalizer-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:72982d9958a42f8132bf2d6b90214ed66477295ef1188731f98ae3511c6eeb5a", size = 198896, upload-time = "2026-08-12T14:33:11.51Z" }, + { url = "https://files.pythonhosted.org/packages/20/2e/04c0bbfc8d9abf91959f7a3d207d45cbf63a8116984caae2381890019bb5/charset_normalizer-3.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:0b373bab0b867b68b8eb249da9478cab9181a42993437cd2f5dba5fb0b4fbd1b", size = 179193, upload-time = "2026-08-12T14:33:12.726Z" }, + { url = "https://files.pythonhosted.org/packages/43/14/d098868dac5ff27e0258f548b1c74c6484be528384965d8fcf8fc6a4011d/charset_normalizer-3.5.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:d95244906ed69d0f79f190893c65e336c15959003e21449256dc05c001b52ea2", size = 211664, upload-time = "2026-08-12T14:33:14.153Z" }, + { url = "https://files.pythonhosted.org/packages/e7/da/a944b32a46601ae5a4c3499e8d64ecd14fe82313f00da74dcdf00273a0b4/charset_normalizer-3.5.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:d788e2ded0c4c47efa4d73cfe59eaf975ee32f425219873d2cb3e3fbaa00f636", size = 224375, upload-time = "2026-08-12T14:33:15.472Z" }, + { url = "https://files.pythonhosted.org/packages/f7/db/eabb5996be2f529744755e7b2fc9396eff4a64961f034e7fd49d54b9afb2/charset_normalizer-3.5.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:f9f91d3e8382900f3a68fa0ce94294479de9cd2de6bc0c70acd0f0dfd511836b", size = 194364, upload-time = "2026-08-12T14:33:16.607Z" }, + { url = "https://files.pythonhosted.org/packages/78/65/4ad3c5be108930310d8003f5602861d5b89f728293b9f09c3a4837f7ba10/charset_normalizer-3.5.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d54625cbf4e6b60bf0639728cb8b4cb541e340f6d7cafae5806051a40ddf4c45", size = 197643, upload-time = "2026-08-12T14:33:17.88Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/8fee3201b98d52289be60a775797d69be05a04fb6cfb48c1587dad33e649/charset_normalizer-3.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:1f99a8c3a1da5d955edbad18208b3d627bdd54c48a6e739fa877bdca98c686d6", size = 341384, upload-time = "2026-08-12T14:33:19.239Z" }, + { url = "https://files.pythonhosted.org/packages/a7/dd/9e757101d1f76c35c0643684ba499ac3a181fb2b264c68174bf727d627e8/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf1e75dc07a3850b53d1e5f75e04d3ae12afe56284be7821771eaa2466350c73", size = 241637, upload-time = "2026-08-12T14:33:20.619Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/7857023015400bc4aa0a82fbcca29fa2dc7ec25f971a130764cb2dc7a589/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ac5a9cc079c67d75f4ddf343276031879eadbb333d1bb231cce297b8d7b9aae8", size = 226170, upload-time = "2026-08-12T14:33:21.773Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ae/8b52935b304f7b6bbf33151ed2b75266b09aa4b6f8f04230d948885b2577/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0dfe83c1b4d00abbf433998117a14f56a5c2bc68226c0d331709eed0d1ce539b", size = 265093, upload-time = "2026-08-12T14:33:22.999Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/6e838f6bb059f2c0afc60a4e7f294252f043c254656ad4114c50302cae4d/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e4e8fa586df2208ef040684751345f10f503834a757c9a74ecd19c1a2f9b1ccd", size = 262789, upload-time = "2026-08-12T14:33:24.214Z" }, + { url = "https://files.pythonhosted.org/packages/c2/08/189b27e51fddc9d6b3695331da0e31792c1d88b953ad854e57f06e9b2cc8/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82cc5835997ec78afe293a192e385099355770a7db94b2fb1239d36b32796f1c", size = 250580, upload-time = "2026-08-12T14:33:25.707Z" }, + { url = "https://files.pythonhosted.org/packages/ac/55/64854e99b25841f83e8e37d9df2f3d1f96f693439f80e5fabd542a7e47ab/charset_normalizer-3.5.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:19e52bda45086df8a4be4bb5910af6f5d9d3b538c78712c8ae09ef10b85bf458", size = 245008, upload-time = "2026-08-12T14:33:26.971Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/7720c904fa635d4260b4dced6029cf3d298c57b26741365d5a8d28c54043/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3418edd0ecb72a0a3861cf72f31be0ad9b7fe338ce2b58fb5cc80b9aeb792700", size = 243892, upload-time = "2026-08-12T14:33:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/70/50/7bfcb327631d4870c720872b548745f6ec8baa044d51c21b5d1d32ac4e3a/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:125ee619611019471b177c70bc3e9d4cda9fad7e01d93523501d3b188df0193a", size = 230996, upload-time = "2026-08-12T14:33:29.511Z" }, + { url = "https://files.pythonhosted.org/packages/24/51/40c45d6d940c04005ed721aa54bdebf1ebb2930f8a2ae537e8d60484fb27/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a3ad0e3da22852533858663848608f3f24c0d35e5cde415a4903476f2b4c88ec", size = 265834, upload-time = "2026-08-12T14:33:30.689Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d4/ef7a227ef89d215b47f9df79c3966610b17faa13bb2f236989207a631622/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:4ebebb410bc517e1d284c52a123e82704b21e4e7e26a21ebecf7439d0647b8a3", size = 245544, upload-time = "2026-08-12T14:33:31.859Z" }, + { url = "https://files.pythonhosted.org/packages/37/a9/a4ca9156964ded61c7718eba410ce11be2fd2b263fda4bcf08367b6578cd/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:91f9f7c151e772acebe489eaec96e96a2877202d7dd144e3f96b8676881715a0", size = 264110, upload-time = "2026-08-12T14:33:33.13Z" }, + { url = "https://files.pythonhosted.org/packages/38/6a/838364bb8702229c6e5f8b23f80ff0f052a12dfaf3113a12fd6acbe92a44/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:401ea6e7af9e7852ed818f64714b579c1935482049670847ca3bd7ba45dc63fb", size = 252303, upload-time = "2026-08-12T14:33:34.98Z" }, + { url = "https://files.pythonhosted.org/packages/9c/5f/d88032edce951f499a2321cf7ae0d35a043c74be12bc22d81084cc7afbcc/charset_normalizer-3.5.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f7496aed56b06325a1ad419c5bf23c6dd042558e874f71dd1b958f3e255f3053", size = 139964, upload-time = "2026-08-12T14:33:36.195Z" }, + { url = "https://files.pythonhosted.org/packages/37/ae/1c4a46b6b00d1c34d2ee355ef99ad6173674166800d1af0f05f85028d513/charset_normalizer-3.5.0-cp314-cp314-win32.whl", hash = "sha256:606a86c1c3196f3738de39a67a7490bbd61cb31c0e0436070bd0c6a48170b38e", size = 179790, upload-time = "2026-08-12T14:33:37.356Z" }, + { url = "https://files.pythonhosted.org/packages/01/51/f94dcf34fa8eba48c1fb89b6490a5f1426e19488fe5f38aac6c648c99057/charset_normalizer-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:ec6c464cf45867f66a2273e2214d9199a8fbad5cb95ca0fd45f6a2fe1d9d2cf4", size = 203723, upload-time = "2026-08-12T14:33:38.639Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ba/91d386870b5d9e4b0d8c4034f63877cc2e47b99c81ef05f3e6d42bf9a53f/charset_normalizer-3.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:dc28949de1bb5f7f30a46f15d74ce7ac5aaa63e03c5de04d68f571c7423af834", size = 183423, upload-time = "2026-08-12T14:33:39.899Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c9/534ecb17b7fb95f9052c4a44cf316316a27d4a8f73e8475ff55e778dcdd7/charset_normalizer-3.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:68b7e84ae8239a94f8d2c8f3f3a3a81bcde54805ec8f42a34de927d155688ec6", size = 368967, upload-time = "2026-08-12T14:33:41.093Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/ad7c3242d7fe55cd55126c22c65cb1b49779782cdf8932fd01d12232d86a/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58ca5dc0a0ef99f2801ec0574214c978e9574055bc783830bbb6e7433218609f", size = 239478, upload-time = "2026-08-12T14:33:42.428Z" }, + { url = "https://files.pythonhosted.org/packages/7e/62/77f0b850048e430fc350ec58876b0c020f5c8d0d3956fd1a4d6ae2fa292f/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6c57af4084c10cb3286688d65e4c654190ff5edcbc2411d08cdca0a8a44c59a1", size = 227036, upload-time = "2026-08-12T14:33:43.635Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ba/47d951e1a51dddbaad0a1410baf49fb1d897ceb00281568f1183b79bce9a/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1619a3cc174a7e3963dd34348e6fceb6e50db0ddeb0031bd7c73a58286454fa", size = 260772, upload-time = "2026-08-12T14:33:44.96Z" }, + { url = "https://files.pythonhosted.org/packages/b0/61/8c7ff4c81b2a88271126acf4b83ab3e31f6d63868b0f01d331eaa0f9cb67/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1328cc57dd4372be1265f68232cee890e087416e3e6e93e6ffb32c2bad4d36a4", size = 259273, upload-time = "2026-08-12T14:33:46.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/fd/36129689be08dc287b951306946657ff70d76e287dd57018861f86d0e474/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06f4fb62a9139bef056b8b2da6773c94c2f259f90e4b8e53b166f3d0372d7cf6", size = 248086, upload-time = "2026-08-12T14:33:47.54Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f8/bcae67f994c8fd31dda445e5ebf84045823c31443fe46f0e9ee6aca99aa0/charset_normalizer-3.5.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:478650a70a750d75d5add401606c77f77069c32e4ba2c9131dc6cee566962ca0", size = 242671, upload-time = "2026-08-12T14:33:48.746Z" }, + { url = "https://files.pythonhosted.org/packages/61/92/0472cdad1061c2f0e4d3aee29973eb6e81bb8fe256ff2860cf115b15f1c9/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:48920bf6fe83eb2226756ac623fa54940487154eb18f80889d5735cf234965c0", size = 241311, upload-time = "2026-08-12T14:33:50.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/89/04a03de5d27c77c624d9fcf6287073754bd438df1b58cb7d030c57c2824d/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:168a0cb536b5123a77bc42ecf5e0bf6f923d0d9ae43c42a14eb0677c19ac6c19", size = 229898, upload-time = "2026-08-12T14:33:51.523Z" }, + { url = "https://files.pythonhosted.org/packages/42/a2/639c4278adcb7ed1f4db608dd9ac19b6774fa2285a96b1c0bdb9c124ccbd/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f278e131afa96a3622cef9211c406ea2ad1b68eb06f8837cd443684a40e0ae50", size = 262852, upload-time = "2026-08-12T14:33:52.924Z" }, + { url = "https://files.pythonhosted.org/packages/9d/95/02e34c97bedfd0c5574efb9179c850591acc7f967ba039ed8dd29d332b73/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d6100f877d2ed95f0856a3fde25334153add94bf2224c43f45f88e7039262aaa", size = 242913, upload-time = "2026-08-12T14:33:54.18Z" }, + { url = "https://files.pythonhosted.org/packages/a3/64/0946aeab6462dad9f160a50dfb4704d3f58a5ee708f085abc2105fbbff0c/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4c440122e1ea68b1f8b44a631ebf49c39180f6869b1da22d76e8a724208ec6e9", size = 257938, upload-time = "2026-08-12T14:33:55.802Z" }, + { url = "https://files.pythonhosted.org/packages/79/77/36787d41ead124746506a4425c729f4f17c68280af8a6a5baa0a598cae86/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e5f834965c2fe589837bac1002e07e25734ff70381903ccd95b3d649e22bfa40", size = 249467, upload-time = "2026-08-12T14:33:57.081Z" }, + { url = "https://files.pythonhosted.org/packages/65/10/d9f6c5589cd24198d4ce6cd2948191c18e657272f433e5a00d258d9f5c22/charset_normalizer-3.5.0-cp314-cp314t-win32.whl", hash = "sha256:076cf9d3f3c7e410295c09d96355cf3b1bcae74990034d80e4371e20fe1ba4c6", size = 190624, upload-time = "2026-08-12T14:33:58.449Z" }, + { url = "https://files.pythonhosted.org/packages/6c/81/43e0584a802051a22c725795ebe1df78263abc7de858eef6cdc9b36637e9/charset_normalizer-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3288a560dc3114d5d2ebe309b1ef43f8af355eafe25856832415c2a8196c9db3", size = 215902, upload-time = "2026-08-12T14:33:59.753Z" }, + { url = "https://files.pythonhosted.org/packages/30/f3/af6a1160fef0eac4510d035241e11eccf78e5350e4cd4de79e79fe02a5e5/charset_normalizer-3.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a284c36b9c6616bf0a8aa4aabba668a0c75ba65ccf40a79868aeaa69ad996897", size = 193452, upload-time = "2026-08-12T14:34:01.017Z" }, + { url = "https://files.pythonhosted.org/packages/42/a4/dee470afb7a55c4f78b6fef37306c51fed17ebf94dbe530798c91d394350/charset_normalizer-3.5.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:c38d1e9bc2073b0984d2099ea647fd7f6c0d8f83a1e14e0cd32926f16e4c44ce", size = 341595, upload-time = "2026-08-12T14:34:02.4Z" }, + { url = "https://files.pythonhosted.org/packages/6a/32/9c3126dc429c6d9d7f79c52681a7c4453ed20a26267c9a8275d7ab620aba/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9f45186390aee4d1f26f723c615b67df346766c3b16df000d84d6e374f06757", size = 242177, upload-time = "2026-08-12T14:34:03.741Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9d/5b616a887301ff4cc0916b39ba44257390d3da80deeed6e8b6f2f26b14a8/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f0fde5e5100c735b2274ab898f0742a5dcde492796296cfbe7e0ad6a4cd1a396", size = 236730, upload-time = "2026-08-12T14:34:04.991Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/d6d3e70be93ebe5fabef65e4c7ac113e1d1705cbaeb5fb72467e713aca17/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d00e18e7bbf47e332ab63903d18bae31efc701b1d8cca0382b97784a621fc44", size = 265158, upload-time = "2026-08-12T14:34:06.235Z" }, + { url = "https://files.pythonhosted.org/packages/30/e7/3f1fafa87e2643257474f9c4eec609f2193a61d907dce7dd4f3f2390ebd5/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b81980668800dd1c69faad8aea6e85a8cee0e13bcd3bba7671695ff16260293", size = 262931, upload-time = "2026-08-12T14:34:07.511Z" }, + { url = "https://files.pythonhosted.org/packages/0a/df/ebeb224a949d91829e5e114c6b64372a3c792b00762a9e951ce416f3a32d/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb3e0d1345b9c0fe73673ea656375f38a78ec679c2edeae0c24800f04798a85", size = 251388, upload-time = "2026-08-12T14:34:08.949Z" }, + { url = "https://files.pythonhosted.org/packages/a0/64/9a6ce2e7acc5cf1b4636f78f82e89ff581e06a0216a40678b28bd4d832c4/charset_normalizer-3.5.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2401f7671242e921e604f609d429f6b282ea4ca787a6ffd22ed7372011ddb9d1", size = 251821, upload-time = "2026-08-12T14:34:10.138Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b1/6e69b8056f615e5ccff6b91ca16db2d47922251f016821a300c115267fef/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:96720f2aeed3434bc48f4d52fbad64ecc820cfed88915d664780ed9ba09ede78", size = 244507, upload-time = "2026-08-12T14:34:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/0f/34/02c15d6a0aa6b934dcdc136b111da63ae857b9fd51cf5505b0736337c2eb/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:c455829625df983f716cbaecbba77f2d1dc2e0e0ed1638c059cece15a279344b", size = 240951, upload-time = "2026-08-12T14:34:12.991Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/0fe893d3e1c7d111280bd6c4bd4c1e431487a1124a1bcbce78dfeda3a3a8/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:c41b067eddcfa5ee6b1169c287605be7fb6b0ea22bba6474c5bb978a668def4f", size = 266162, upload-time = "2026-08-12T14:34:14.232Z" }, + { url = "https://files.pythonhosted.org/packages/55/ea/eca03527307670f5d102c295671a800c404ca958cf94fefd10fc963a72f0/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:e31786a947b136329bfdc458c82c06d4ec539b4a4436b7da4df4aafc9902ee80", size = 251835, upload-time = "2026-08-12T14:34:15.48Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/fee5633081e595fe9e191df6f215106c791ad596eddf5e41e39b8ea0f2e2/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:c5c6d47a865147e0ae3322ce92e7fb52ba3169d94b447deda56897ea2aa6fac9", size = 264314, upload-time = "2026-08-12T14:34:16.679Z" }, + { url = "https://files.pythonhosted.org/packages/cf/fb/17f47ae6ca35b562fb6e6f4b05f7aec6034217353eb4a23aaa3566dc7340/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c75191e3c8052045179646cb40e280800a4e0bdfda34d9c949c2f268d44e80e4", size = 253194, upload-time = "2026-08-12T14:34:17.975Z" }, + { url = "https://files.pythonhosted.org/packages/59/88/f2b0f7ebb92493e925889ff29239b3b0073ffafd91230dbfc69e5cf9389c/charset_normalizer-3.5.0-cp315-cp315-win32.whl", hash = "sha256:83b62410bd36bb1178a7d563e2ee0cf21eb1c980c912ab99c2c78f06227f1731", size = 179800, upload-time = "2026-08-12T14:34:19.409Z" }, + { url = "https://files.pythonhosted.org/packages/e2/f0/afb5bfdea52fd943b1960403847a276b8e900c6e4cd6a38752321b4eda64/charset_normalizer-3.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:e3b9eaa99a6d8c9ace4cd303915947ef55088d4cd87c6676874f98c5c03aa040", size = 203726, upload-time = "2026-08-12T14:34:20.656Z" }, + { url = "https://files.pythonhosted.org/packages/fc/71/219783eb691aa2ec879c0e521afdfe2b826f9678eed51b9c039d03e0db2b/charset_normalizer-3.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:fec352b793cdc183cc9e7e0b6c10fd7bff38ec54ba44cc43599b9b56f7f3db2e", size = 183428, upload-time = "2026-08-12T14:34:21.975Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d0/14aef3b9f80f2593c039d897e89034635b9eb0eb44b6ce5173bbd79ff338/charset_normalizer-3.5.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:c9bde7a960720c8b8e1b5ef7afaa0c9a2f3b55c44abd635b2b29dd066b298e3a", size = 368728, upload-time = "2026-08-12T14:34:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/10/fc/b249466ddbbeffa448b6597631e9091d1f01b5132ff8e7a0e21a6eb72b63/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8cd1283a9fe6c2065c807e9d5da81afe5e1e004caef39adc0d8ae86dd883698", size = 240925, upload-time = "2026-08-12T14:34:24.504Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b9/c17e72aaa1b3e1ca6c184e8025cf138ed492d01a54f85286ff7d31253a4b/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1c010dd86d3f4c4433c9634d33ce8147393b270dfa54f217f965540b8ae8e075", size = 234932, upload-time = "2026-08-12T14:34:25.822Z" }, + { url = "https://files.pythonhosted.org/packages/18/d7/f84ef0966bbe216f71029e34e7fa425a16b1682e2a40265e679dedf2b655/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5e68229977b2dea28e7061c0c0630a23f2f9f6e9c6fb38d77d3d6dbfe3768b74", size = 261733, upload-time = "2026-08-12T14:34:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/3b/73/3e887fa0781a395339355ed934ab6561ceb5bb52574160f070224039c630/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a60773eb5fda796e6e6f76b9c152d270fe59f9788a51a6ff8ba44082d8548ae4", size = 258460, upload-time = "2026-08-12T14:34:28.431Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/52ebd9849bf9e35d0b21fff115cb6543162a8e1f2f564e8f87121a336b8c/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9419f44e568f7fafcdc0b3b5c766a2364e705a9b34fb8a56b431e0d1f3f4258", size = 249894, upload-time = "2026-08-12T14:34:29.698Z" }, + { url = "https://files.pythonhosted.org/packages/85/f3/9366492b8a5fe0187de282e001d61345740cf79eb4a5f20181d769be02b5/charset_normalizer-3.5.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75e243abbb528c1a774390ed71e3f868a9f37b1373442e4bbadd401cfc505ff4", size = 249540, upload-time = "2026-08-12T14:34:30.96Z" }, + { url = "https://files.pythonhosted.org/packages/0b/af/28bb5e5dbd3e67cb9196a62781ac2b6d79492f4fc7a069b6ca7d6d6c8d58/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:54c963ce6404e52255b737e8a06d356fc762d59096ae566203a67cf2b7d050f2", size = 242734, upload-time = "2026-08-12T14:34:32.481Z" }, + { url = "https://files.pythonhosted.org/packages/26/d6/7ccfa62b53b40fc06b2d3504825aa400764740bd10cf248fdc4272441b93/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:63ea0cc840c66670183578c2630d138c0e944aeadfc33f25173ee240f5db780d", size = 239580, upload-time = "2026-08-12T14:34:33.916Z" }, + { url = "https://files.pythonhosted.org/packages/0b/82/71c0c9b046697b8da66b3acefa8d5f92d00a9ef433ad7c3522b971d0369a/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6c06875a1d4a7537bef70f659b55c6b55b9a47ec3ba8f2db610350c2d9915e6e", size = 263281, upload-time = "2026-08-12T14:34:35.333Z" }, + { url = "https://files.pythonhosted.org/packages/d6/01/d027583c869f40ba980c1c76994adbd522c360a6327e72beb44d7c267385/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:4253da1b4456b633651a8d59eb1dc7a8a8fa38241014dd7c217b353e547ae394", size = 250027, upload-time = "2026-08-12T14:34:36.991Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e9/6475d739e0ec8bb1236e06263dc3affaffdf947d8114ad27024932f325da/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:f044cb1cf44012184715f46584658993b5fee9344d71c4b0c455a17a299730c0", size = 257547, upload-time = "2026-08-12T14:34:38.277Z" }, + { url = "https://files.pythonhosted.org/packages/a5/60/d1f502fcaa048a2aca3ab80bfef8407659c131e4f1792fa805fec14b4960/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a17864853f7c518ae7d4b368af98f427f9396805476af40af8698560f09d7d97", size = 251718, upload-time = "2026-08-12T14:34:39.562Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/76343dcf4381a698807ff8a20d89f66bbdd9f6222b0b17740f77ab764335/charset_normalizer-3.5.0-cp315-cp315t-win32.whl", hash = "sha256:9e726478d7a213847860219d74665a6892a643ac93b8f76580f6cf9ed39996b7", size = 190757, upload-time = "2026-08-12T14:34:41.053Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ea/d18147626a1667cc773c42104ab155a4ca5d6d4d174b7a35e01062213ea5/charset_normalizer-3.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:d7229a99120c6c2792d96f4857c2648ce5530e93667a2c2388c5ef69a6b84775", size = 215431, upload-time = "2026-08-12T14:34:42.501Z" }, + { url = "https://files.pythonhosted.org/packages/47/21/4869598aae0872d94faa5933918a4fe37ab2c5af9d095786e241f9506fed/charset_normalizer-3.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:527e28a5e751d9e11369b9c5f9ab35c748eb9c109101920c7deb40d6eadf8d03", size = 193205, upload-time = "2026-08-12T14:34:43.781Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f3/7b523d807cb5e73562ef8acf21d39cdb9d704955327362c781bc3478a73d/charset_normalizer-3.5.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:5a4ee37248dfac25107c758bda99d545ce73e60b44d2dd39e4a2bb9f2831e9f5", size = 330840, upload-time = "2026-08-12T14:34:45.06Z" }, + { url = "https://files.pythonhosted.org/packages/f0/de/fc68978fe78ca97063c96d764e41ff92ca639948f319271e0ff450e577a2/charset_normalizer-3.5.0-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a864bdcacd8bff58bb4845304e031f821a3ec64b2b7259f2d409cd49c9e59ca3", size = 251862, upload-time = "2026-08-12T14:34:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/a9/cb/82b41a0ab7fb1a88065f1d78ad32696ad88ea3fe8e25b8189d08833938de/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84b736e3b391601bc47b86da381c749c0f894e9191aaca9f31f30c2632206df3", size = 239484, upload-time = "2026-08-12T14:34:47.869Z" }, + { url = "https://files.pythonhosted.org/packages/e8/0c/19608b631f4538f908098d4a2d56a8f79a665e27cc58e9d90479761a9227/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6abb1f356fb865baeb6ebc3fadd843e9a96fbf49b9adcca55037f3cceccb7438", size = 230602, upload-time = "2026-08-12T14:34:49.265Z" }, + { url = "https://files.pythonhosted.org/packages/29/db/f648eb30e14eba301aed61e11672156f137905c1bdbb530151abe8065943/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d366548d2ee28a8cfdcc4296363978cc644a728333be9824d2de4652e83df0a", size = 259208, upload-time = "2026-08-12T14:34:50.632Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e0/ed2c8bdbac484d69614d6993143aeb6cb0f4dd1561c883402517b623c8ef/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d672f329ae504ee240eb39b6effb3318aa8e7e8924c0ce8eee5760b3fad98539", size = 253659, upload-time = "2026-08-12T14:34:52.11Z" }, + { url = "https://files.pythonhosted.org/packages/32/08/b4907cb9ec5b521d9d024ced13611240b86ef065c2eb15b3ad2334dc9940/charset_normalizer-3.5.0-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c54036a518748b6c02e666f6d46c3817561998fb904c3be25b56fb4fe3dc5706", size = 248821, upload-time = "2026-08-12T14:34:53.399Z" }, + { url = "https://files.pythonhosted.org/packages/12/b2/e2d1abcfbc05822f0030869efb4e9f8a3658e13b4821796d4b62da917327/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:22a1889f1c9b752c63c36758a0c2145458e3cadb20fced7a0790002e9dd12b26", size = 240271, upload-time = "2026-08-12T14:34:55.09Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f9/4ba127ad610542fa3eabfa41c45bf12d357860a815b3566374ec0188e213/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b7eb3eab5c646d3de7dcb14a7c9caebace5249c5767da39e1761cb1576e521a3", size = 232155, upload-time = "2026-08-12T14:34:56.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/68/40613182366d00bd6dbd5f6c84a926cbd120960e038a8269e9ae7d782762/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:2080aa129a28267984cdc902898993d788c995c384e285d0d19199f56760d52e", size = 259674, upload-time = "2026-08-12T14:34:57.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/53/4574a14fa4c9de4a6c9f31725354bfa40b67f653e6d594ce1654f9a41b32/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:fd68c825548a611158230e2f9222e210ceb2e3391995c0aa5865cbdf3ab4bd49", size = 246122, upload-time = "2026-08-12T14:34:59.337Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/bd8030d13d92c058ca7b2b9615bbb3169569e144db64d65c149cd45abf5e/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:d8a9316f4da85e937242642b537c6d55d7e9287dd38e5634732f8233932aff45", size = 255221, upload-time = "2026-08-12T14:35:00.71Z" }, + { url = "https://files.pythonhosted.org/packages/77/9d/10ecd3bcbe2666b3d4d4026c97b48f73990682815db516052a1e8f4a31c5/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d90254c8f609338c53ec180fcd4c4f9c16502e238e3fc88ca7fd4c2f38d445b8", size = 253450, upload-time = "2026-08-12T14:35:02.217Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/d044c4872c0938a84f87b5027a698c0e61bacfc5c3551a4e749ca9b7bc5c/charset_normalizer-3.5.0-cp37-abi3-win32.whl", hash = "sha256:8b3e9e29b8b07cc461b9ce7768db7693a93979d0dadf22046f6f3555ded2f516", size = 173594, upload-time = "2026-08-12T14:35:03.845Z" }, + { url = "https://files.pythonhosted.org/packages/10/6b/6046773901f1944b9a89436351529811ee958afc7b774563be9d74a6f0c3/charset_normalizer-3.5.0-cp37-abi3-win_amd64.whl", hash = "sha256:0c8953d9d1617794cfc40d81179571c9ba3805dd029623a15c93f1fb70e60a74", size = 198959, upload-time = "2026-08-12T14:35:05.187Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a6/b57708ac92aefc8e8389d51d5178129b81f03196da61ee2c23e687b8178a/charset_normalizer-3.5.0-cp37-abi3-win_arm64.whl", hash = "sha256:562d24ca7797c1af8852994950c2e623a907b201fc4b0ed29e92af173d3828ca", size = 267055, upload-time = "2026-08-12T14:35:06.533Z" }, + { url = "https://files.pythonhosted.org/packages/22/c7/754d09943a616937df61e4ba367c409ded2a987e872972098d51a6fcf73b/charset_normalizer-3.5.0-py3-none-any.whl", hash = "sha256:993dfcbe75a85a3784abb5084f2c41b915767c90546fcc92803cffa28611baea", size = 67943, upload-time = "2026-08-12T14:35:30.363Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cpex" +version = "0.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastapi" }, + { name = "httpx", extra = ["http2"] }, + { name = "inquirer" }, + { name = "jinja2" }, + { name = "mcp" }, + { name = "orjson" }, + { name = "packaging" }, + { name = "prometheus-client" }, + { name = "prometheus-fastapi-instrumentator" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pygithub" }, + { name = "pyyaml" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/ed/85fea0d1a91a4b10fe2bc00ea61879ebf4b87c5e17a16c3fad6f9dddac8e/cpex-0.1.3.tar.gz", hash = "sha256:a9e2bccc82e8f8ac54839a9aef434193e943e0c6eca2d5464f3011ef59f7302f", size = 2390201, upload-time = "2026-08-06T20:02:23.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/b6/406951f08b00aff2dd24d54fab16413ca4a72906305fec61245fdfc0cbf7/cpex-0.1.3-py3-none-any.whl", hash = "sha256:a5d33bcd1b4328b36e9798a1258e9d6465e073c784e5b9d1c4588f1b93054848", size = 279062, upload-time = "2026-08-06T20:02:21.814Z" }, +] + +[[package]] +name = "cpex-output-length-guard" +source = { editable = "." } +dependencies = [ + { name = "cpex" }, + { name = "mcp" }, +] + +[package.dev-dependencies] +dev = [ + { name = "maturin" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "cpex", specifier = ">=0.1.3,<0.2" }, + { name = "mcp", specifier = "<2" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "maturin", specifier = ">=1.13.3" }, + { name = "pytest", specifier = ">=9.1.1" }, + { name = "pytest-asyncio", specifier = ">=1.3.0" }, +] + +[[package]] +name = "cryptography" +version = "50.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" }, + { url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" }, + { url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" }, + { url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" }, +] + +[[package]] +name = "editor" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "runs" }, + { name = "xmod" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/5f/fe06c2a13a5282dcef4c7133bb348d4125a9aa69c5fb49037a004599d73a/editor-1.8.0.tar.gz", hash = "sha256:b07e1bbcb8b33f05c2e6ed3ce77ee9756354ada840a18aad7c0536d967fe4c0b", size = 27455, upload-time = "2026-05-09T13:42:59.796Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/b8/6648cadf38b61045262bf2a534e526f04eb833275ad1fc77a2026e9f7e3a/editor-1.8.0-py3-none-any.whl", hash = "sha256:7d47ff88ae6c5f6c43d28c30b6f7fd59a24741175a1771ab06c969d946d7dfd0", size = 4012, upload-time = "2026-05-09T13:43:00.847Z" }, +] + +[[package]] +name = "fastapi" +version = "0.141.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "h2" +version = "4.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/85/7c366e69d84c17bb778fe41419e1fbcce3033d5b7ce29bbffff0a98b859f/h2-4.4.1.tar.gz", hash = "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516", size = 2157281, upload-time = "2026-08-03T11:45:09.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/22/e85faf23bd72a92d1921e37d674ca56eb298a3c8be31fdecef0ff2b3aaac/h2-4.4.1-py3-none-any.whl", hash = "sha256:0e25f1462b23c9cb82d9eb02e28bc706dac2a68cb457c6a0d74d63c8a2a5d0e6", size = 62636, upload-time = "2026-08-03T11:44:59.164Z" }, +] + +[[package]] +name = "hpack" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "inquirer" +version = "3.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blessed" }, + { name = "editor" }, + { name = "readchar" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/79/165579fdcd3c2439503732ae76394bf77f5542f3dd18135b60e808e4813c/inquirer-3.4.1.tar.gz", hash = "sha256:60d169fddffe297e2f8ad54ab33698249ccfc3fc377dafb1e5cf01a0efb9cbe5", size = 14069, upload-time = "2025-08-02T18:36:27.901Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/fd/7c404169a3e04a908df0644893a331f253a7f221961f2b6c0cf44430ae5a/inquirer-3.4.1-py3-none-any.whl", hash = "sha256:717bf146d547b595d2495e7285fd55545cff85e5ce01decc7487d2ec6a605412", size = 18152, upload-time = "2025-08-02T18:36:26.753Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jinxed" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ansicon", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/d7/6e6d474ec5eaeca6a61acc17766bb19563b3a372b4b9d92910078f5fe49f/jinxed-2.1.0.tar.gz", hash = "sha256:7e755b831faa2443d44fb4ce7c0202eb9c3ed39bd5bf1193365888f4f6092b54", size = 61287, upload-time = "2026-07-03T15:46:48.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/1d/0a6be99b69bb448448403a45a241aff0dca751832ae54f027ed940d2eb52/jinxed-2.1.0-py2.py3-none-any.whl", hash = "sha256:43b802d18b70e405d410fb66eb2837d1101e7e5ea922e666507bb43f34d11d09", size = 104999, upload-time = "2026-07-03T15:46:47.114Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "maturin" +version = "1.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/b3/addd877f871fb1860d46d3a4f206ecb10b946c85846805e6367631926fd3/maturin-1.14.1.tar.gz", hash = "sha256:9d6577a62cd08e0ceba7a0db06fb098e0c9b1b3429bad747a4f3a18215a1b3df", size = 369637, upload-time = "2026-06-19T05:19:49.774Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/f0/97c5a5bd9c71653a066c0976a484eaaae50b9369557838a4176b7b0bdaa5/maturin-1.14.1-py3-none-linux_armv6l.whl", hash = "sha256:522292398945442cdafa9daeb2271b2340fbde57027b818f923f88eab04174f8", size = 10207496, upload-time = "2026-06-19T05:19:09.321Z" }, + { url = "https://files.pythonhosted.org/packages/fe/83/294bca639b0e052f1e2f65199b3db258780c7d4e31408b934c9c974a1379/maturin-1.14.1-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:ffe5ad71f21d1e6603c4dd75f7fee34adf5ed5ebcebb692886549888ebb329ed", size = 19680113, upload-time = "2026-06-19T05:19:13.43Z" }, + { url = "https://files.pythonhosted.org/packages/43/b6/79c881410a3b1c187f7eb3d407aecae646c6a4433d630d72200359015e83/maturin-1.14.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f3306078070c1508fd715b9116070cbcaff5959024272a9f1e6f5cb29768b86c", size = 10169205, upload-time = "2026-06-19T05:19:16.615Z" }, + { url = "https://files.pythonhosted.org/packages/93/9d/44b6f26dcb7f7a04c5501ac2dbb6ca1490150682baa525ca5860504f9eab/maturin-1.14.1-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:cd457cd88961156e26379e1155bd287cc0ec1c8b2f1582b0660fb31b87c8842d", size = 10188098, upload-time = "2026-06-19T05:19:19.736Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bd/9c0d5d6983905ce2c9edaa073a7e89355a9cf7f396988e05d32f1c37785d/maturin-1.14.1-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:dfc54ae32e6fcb18302193ab9a30b0b25eefffba994ae13238974805533ef75e", size = 10627576, upload-time = "2026-06-19T05:19:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/e5/33/b096412bd6a7cb399652b260666f901adf88a687181a6dbd6a3f89f0a94e/maturin-1.14.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:a131d912b5267e640bc96d70f4914e10590aed64082ec9abacba7cea52004224", size = 10085181, upload-time = "2026-06-19T05:19:25.69Z" }, + { url = "https://files.pythonhosted.org/packages/56/8d/08c3bf469c38a23c9e6c877e338193001eb604d010fedc08341974e38528/maturin-1.14.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:be18fc568fb76884c0205456336892a75105ec398e6b667cd777c6268bd06d69", size = 10026363, upload-time = "2026-06-19T05:19:28.904Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a4/c4d1a92839f8745ab4aab988a7db884a79d6d710bd3b286fcf9316dece1a/maturin-1.14.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:994a0c8ba3ad8a92b3a9ee1b02645d200d610216b15cff5102b0fe65e8e08666", size = 13321347, upload-time = "2026-06-19T05:19:32.411Z" }, + { url = "https://files.pythonhosted.org/packages/b3/fa/170f04624d03fd07d2a8b1b67de83a127af93aef9eaa425839553347297b/maturin-1.14.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be80866363e605d137991b491a741a84cde9ae350183c4c85f49690ca9aaaa65", size = 10877609, upload-time = "2026-06-19T05:19:35.448Z" }, + { url = "https://files.pythonhosted.org/packages/61/ad/1ae2e1d0ded282bf2c55ac13f0811d87deb425e200ae64a15785675dede9/maturin-1.14.1-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:5282dffd4b539d2be245f4e5b1a5ab6bc1033b58f4a4872f5833f9d43c954aa4", size = 10417316, upload-time = "2026-06-19T05:19:38.28Z" }, + { url = "https://files.pythonhosted.org/packages/fb/27/bf677183920718da49cd7982d6a3ffc440aad8919329f571d189f81b7bdf/maturin-1.14.1-py3-none-win32.whl", hash = "sha256:1a04de0a20188f95c721b5702eed18140bdcccb28c386797093eca3f62f4d4e0", size = 8931293, upload-time = "2026-06-19T05:19:41.183Z" }, + { url = "https://files.pythonhosted.org/packages/63/4b/585adeb9167b08d3cdff0032a938b0e72655c92003df4f52c3f696a1bcc2/maturin-1.14.1-py3-none-win_amd64.whl", hash = "sha256:3c9f94640ecc4895e94abaf834a0684430032c865b2748a36c12461fd9252fdd", size = 10314067, upload-time = "2026-06-19T05:19:44.389Z" }, + { url = "https://files.pythonhosted.org/packages/51/d4/dac8c0720ae246be1700afb6fbdbbea20fe35b13f6570b2f70faa005df77/maturin-1.14.1-py3-none-win_arm64.whl", hash = "sha256:15cea8fcb3ba47dd636f50092bb34baea8b04ac777392f23e6bf8a9a61efb894", size = 9718943, upload-time = "2026-06-19T05:19:47.49Z" }, +] + +[[package]] +name = "mcp" +version = "1.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/d3/f9acc21dfc886e4f78e2add1a47db46ce16884346afde53f8a064c02c891/mcp-1.29.0.tar.gz", hash = "sha256:52d01f334de1868cc3bb2d6604931126a67631f99a6c5d3b82ba47290315ec36", size = 643148, upload-time = "2026-07-28T13:41:41.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/c8/248b201f6d753d69fd5d6506011abbb35a946d9142b2ae311a948fd0be3d/mcp-1.29.0-py3-none-any.whl", hash = "sha256:f5a075bb611f23d6f4d080c6a1699fa62772eebc562ba9e66b306ddde1c755f7", size = 223436, upload-time = "2026-07-28T13:41:40.337Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/51/3fb9e65ae76ee97bd611869a503fa3fc0a6e81dd8b737cf3003f682df7ff/orjson-3.11.9-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:f01c4818b3fc9b0da8e096722a84318071eaa118df35f6ed2344da0e73a5444f", size = 228522, upload-time = "2026-05-06T15:09:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/16/fa/9d54b07cb3f3b0bfd57841478e42d7a0ece4a9f49f9907eecf5a45461687/orjson-3.11.9-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:3ebca4179031ee716ed076ffadc29428e900512f6fccee8614c9983157fcf19c", size = 128463, upload-time = "2026-05-06T15:09:37.063Z" }, + { url = "https://files.pythonhosted.org/packages/88/b1/6ceafc2eefd0a553e3be77ce6c49d107e772485d9568629376171c50e634/orjson-3.11.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48ee05097750de0ff69ed5b7bbcf0732182fd57a24043dcc2a1da780a5ead3a5", size = 132306, upload-time = "2026-05-06T15:09:38.299Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/f11311285324a40aab1e3031385c50b635a7cd0734fdaf60c7e89a696f60/orjson-3.11.9-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6082706765a95a6680d812e1daf1c0cfe8adec7831b3ff3b625693f3b461b1c", size = 127988, upload-time = "2026-05-06T15:09:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/9e/85/0ef63bcf1337f44031ce9b91b1919563f62a37527b3ea4368bb15a22e5d7/orjson-3.11.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:277fefe9d76ee17eb14debf399e3533d4d63b5f677a4d3719eb763536af1f4bd", size = 135188, upload-time = "2026-05-06T15:09:40.957Z" }, + { url = "https://files.pythonhosted.org/packages/05/94/b0d27090ea8a2095db3c2bd1b1c96f96f19bbb494d7fef33130e846e613d/orjson-3.11.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03db380e3780fa0015ed776a90f20e8e20bb11dde13b216ce19e5718e3dfba62", size = 145937, upload-time = "2026-05-06T15:09:42.249Z" }, + { url = "https://files.pythonhosted.org/packages/09/eb/75d50c29c05b8054013e221e598820a365c8e64065312e75e202ed880709/orjson-3.11.9-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33d7d766701847dc6729846362dc27895d2f2d2251264f9d10e7cb9878194877", size = 132758, upload-time = "2026-05-06T15:09:43.945Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/360686f39348aa88827cb6fbf7dc606fd41c831a35235e1abf1db8e3a9e6/orjson-3.11.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:147302878da387104b66bb4a8b0227d1d487e976ce41a8501916161072ed87b1", size = 133971, upload-time = "2026-05-06T15:09:45.239Z" }, + { url = "https://files.pythonhosted.org/packages/0e/30/3178eb16f3221aeef068b6f1f1ebe05f656ea5c6dffe9f6c917329fe17a3/orjson-3.11.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3513550321f8c8c811a7c3297b8a630e82dc08e4c10216d07703c997776236cd", size = 141685, upload-time = "2026-05-06T15:09:46.858Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f1/ff2f19ed0225f9680fafa42febca3570dd59444ebf190980738d376214c2/orjson-3.11.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c5d001196b89fa9cf0a4ab79766cd835b991a166e4b621ba95089edc50c429ff", size = 415167, upload-time = "2026-05-06T15:09:48.312Z" }, + { url = "https://files.pythonhosted.org/packages/9b/61/863bddf0da6e9e586765414debd54b4e58db05f560902b6d00658cb88636/orjson-3.11.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:16969c9d369c98eb084889c6e4d2d39b77c7eb38ceccf8da2a9fff62ae908980", size = 147913, upload-time = "2026-05-06T15:09:49.733Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4081492586d75b073d60c5271a8d0f05a0955cabf1e34c8473f6fcd84235/orjson-3.11.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:63e0efbc991250c0b3143488fa57d95affcabbfc63c99c48d625dd37779aafe2", size = 136959, upload-time = "2026-05-06T15:09:51.311Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bd/70b6ab193594d7abb875320c0a7c8335e846f28968c432c31042409c3c8d/orjson-3.11.9-cp311-cp311-win32.whl", hash = "sha256:14ed654580c1ed2bc217352ec82f91b047aef82951aa71c7f64e0dcb03c0e180", size = 131533, upload-time = "2026-05-06T15:09:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/3f/17/1a1a228183d62d1b77e2c30d210f47dd4768b310ebe1607c63e3c0e3a71e/orjson-3.11.9-cp311-cp311-win_amd64.whl", hash = "sha256:57ea77fb70a448ce87d18fca050193202a3da5e54598f6501ca5476fb66cfe02", size = 127106, upload-time = "2026-05-06T15:09:54.204Z" }, + { url = "https://files.pythonhosted.org/packages/b8/95/285de5fa296d09681ee9c546cd4a8aeb773b701cf343dc125994f4d52953/orjson-3.11.9-cp311-cp311-win_arm64.whl", hash = "sha256:19b72ed11572a2ee51a67a903afbe5af504f84ed6f529c0fe44b0ab3fb5cc697", size = 126848, upload-time = "2026-05-06T15:09:55.551Z" }, + { url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" }, + { url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" }, + { url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" }, + { url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" }, + { url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" }, + { url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, + { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, + { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" }, + { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" }, + { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" }, + { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" }, + { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" }, + { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" }, + { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, + { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "prometheus-client" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/73/f1334c29c2af4cd9dba6c7817e61b611bd0215e2eb5565c6064a4de18802/prometheus_client-0.26.0.tar.gz", hash = "sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b", size = 92910, upload-time = "2026-07-24T19:36:41.893Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl", hash = "sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6", size = 64494, upload-time = "2026-07-24T19:36:40.854Z" }, +] + +[[package]] +name = "prometheus-fastapi-instrumentator" +version = "8.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prometheus-client" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/f4/cdcebf7094b03b99fba71ac8f56bd6f227973642662f49d272332d8419b3/prometheus_fastapi_instrumentator-8.1.0.tar.gz", hash = "sha256:b77f3043665e8d28e2bbd21017506195a43d9adf1d402d01bf95b494b7e560e1", size = 20492, upload-time = "2026-07-26T11:12:44.202Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/b9/91a2246e6cf01b7ccb14479803c8a50f9c258ae5c6a0f16ba3294820632b/prometheus_fastapi_instrumentator-8.1.0-py3-none-any.whl", hash = "sha256:b9f40b2cff3f7891ca0610b3ae4fc6ec723fd326b04bb659819aaeb821a0fc7d", size = 19649, upload-time = "2026-07-26T11:12:45.168Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, +] + +[[package]] +name = "pygithub" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyjwt", extra = ["crypto"] }, + { name = "pynacl" }, + { name = "requests" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/c3/8465a311197e16cf5ab68789fe689535e90f6b61ab524cc32a39e67237ae/pygithub-2.9.1.tar.gz", hash = "sha256:59771d7ff63d54d427be2e7d0dad2208dfffc2b0a045fec959263787739b611c", size = 2594989, upload-time = "2026-04-14T07:26:13.622Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/aa/81a5506f089a26338bff17535e4339b3b22049ebd1bcdeff756c4d7a7559/pygithub-2.9.1-py3-none-any.whl", hash = "sha256:2ec78fca30092d51a42d76f4ddb02131b6f0c666a35dfdf364cf302cdda115b9", size = 449710, upload-time = "2026-04-14T07:26:12.382Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pynacl" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" }, + { url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" }, + { url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" }, + { url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" }, + { url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" }, + { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" }, + { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "readchar" +version = "4.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/49/a10341024c45bed95d13197ec9ef0f4e2fd10b5ca6e7f8d7684d18082398/readchar-4.2.2.tar.gz", hash = "sha256:e3b270fe16fc90c50ac79107700330a133dd4c63d22939f5b03b4f24564d5dd8", size = 9762, upload-time = "2026-04-06T19:45:54.226Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ca/36133653e00939922dd1416f4c56177361289172a30563fcb9552c9ccde4/readchar-4.2.2-py3-none-any.whl", hash = "sha256:92daf7e42c52b0787e6c75d01ecfb9a94f4ceff3764958b570c1dddedd47b200", size = 9401, upload-time = "2026-04-06T19:45:52.993Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +] + +[[package]] +name = "runs" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "xmod" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/ae/095cb626504733e288a81f871f86b10530b787d77c50193c170daaca0df1/runs-1.3.0.tar.gz", hash = "sha256:cca304b631dbefec598c7bfbcfb50d6feace6d3a968734b67fd42d3c728f5a05", size = 4585, upload-time = "2026-02-03T15:59:58.974Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/b6/049c75d399ccf6e25abea0652b85bf7e7e101e0300aa9c1d284ad7061c0b/runs-1.3.0-py3-none-any.whl", hash = "sha256:e71a551cfa8da9ef882cac1d5a108bda78c9edee5b8d87e37c1003da5b6a7bed", size = 6406, upload-time = "2026-02-03T15:59:59.96Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.4.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/00/b42a44342a054d58cb1115d7c8aa9cb4290dd9442f9c1b91a4b8173dba22/sse_starlette-3.4.8.tar.gz", hash = "sha256:ed89ffbb75cbf78a5fe2f2109cd584792ee7f9dfac96f791db546df8f15f3f9c", size = 32548, upload-time = "2026-08-05T11:19:49.982Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/3a/764912c58293d95b6dcdf4cc255f9d10de310580ced547b082eb9d72018c/sse_starlette-3.4.8-py3-none-any.whl", hash = "sha256:6e82314c786709a3cd9520f2285cf9fff90e181e598e8a357b0cf80f66afba0d", size = 16516, upload-time = "2026-08-05T11:19:48.748Z" }, +] + +[[package]] +name = "starlette" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.52.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/28/64ca011edf31c715b4fad359c587ea52391aaffa125065695590241ff617/uvicorn-0.52.3.tar.gz", hash = "sha256:18857b9e6579300be55c91c0a1cfd37d9a2cf0cabea33b88275f199eb73b8b58", size = 100621, upload-time = "2026-08-13T16:50:02.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/2b/ebd108734a8204c6b4b93c681c9a38c5273b3ccd5d129fee4ffc1d97772c/uvicorn-0.52.3-py3-none-any.whl", hash = "sha256:116af2710dbf47c80f463cd20ee4884b6662f4c9f227d797ddc7279d2fcc2c7c", size = 79859, upload-time = "2026-08-13T16:50:01.323Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, +] + +[[package]] +name = "xmod" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/3b/5a0d2670bab661164e27a5c27c448ae6204458c97cb94ccf89d0c47715bc/xmod-1.10.0.tar.gz", hash = "sha256:b40b2a54d56684b01eb9627892b0c179918e8ef0bd4d7f3bac7a3fdba11cd6e6", size = 17862, upload-time = "2026-05-09T13:46:46.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/e9/c362d47ed2b1928d65d61888c1419fae8ef69417fba2067d2fd3da1d400b/xmod-1.10.0-py3-none-any.whl", hash = "sha256:bebf8493b7ac63097401590a329c9ed20da224de0583a522e7ccb634af122f5a", size = 4661, upload-time = "2026-05-09T13:46:45.776Z" }, +] From 9ca8d4e28b1299393e153c56e129aec2dcfe4028 Mon Sep 17 00:00:00 2001 From: prakhar-singh1928 Date: Mon, 24 Aug 2026 14:56:51 +0100 Subject: [PATCH 04/16] fix(output_length_guard): emit observability metrics for MCP CallToolResult dicts Two related fixes in process_mcp_items_result / handle_mcp_content_dict: 1. process_mcp_items_result return type extended from Result<(Vec>, bool), Py> to Result<(Vec>, bool, usize, usize), Py> The two new fields are total_chars_seen and items_modified_count, tallied on each TextResult::Modified arm (both text and resource items). 2. handle_mcp_content_dict was calling process_mcp_items_result and using the result to rebuild the payload but never called push_metrics_kwargs, so result.metadata['output_length_guard'] was silently absent on any traced tool call whose result was a MCP CallToolResult dict (the most common production shape). Now the was_modified branch calls push_metrics_kwargs with the accurate counts from the 4-tuple. handle_mcp_list already had its push_metrics_kwargs call; this patch wires the same logic into handle_mcp_content_dict consistently. All checks pass: cargo clippy -p output_length_guard -- -D warnings ok cargo fmt -- --check ok cargo test -p output_length_guard 63/63 ok make test-integration 21/21 ok Signed-off-by: prakhar-singh1928 --- .../output_length_guard/src/plugin.rs | 41 +++++++++++-------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/plugins/rust/python-package/output_length_guard/src/plugin.rs b/plugins/rust/python-package/output_length_guard/src/plugin.rs index 3f3e126..871a23e 100644 --- a/plugins/rust/python-package/output_length_guard/src/plugin.rs +++ b/plugins/rust/python-package/output_length_guard/src/plugin.rs @@ -172,22 +172,13 @@ impl OutputLengthGuardPluginCore { trace_id: Option<&str>, _name: &str, ) -> PyResult> { - let mut total_chars: usize = 0; - let mut items_modified: usize = 0; - - let (out_items, was_modified) = match self.process_mcp_items_result(py, list, trace_id)? { - Ok(r) => r, - Err(violation) => return build_blocked_result(py, trace_id, violation), - }; + let (out_items, was_modified, total_chars, items_modified) = + match self.process_mcp_items_result(py, list, trace_id)? { + Ok(r) => r, + Err(violation) => return build_blocked_result(py, trace_id, violation), + }; if was_modified { - // tally chars for metrics (approximate: sum lengths of truncated items) - for item in &out_items { - if let Ok(s) = item.bind(py).extract::() { - total_chars += s.len(); - items_modified += 1; - } - } let new_list = PyList::new(py, out_items)?; let new_result_obj = new_list.into_any().unbind(); let new_payload = clone_payload_with_attr(py, payload, "result", &new_result_obj)?; @@ -323,7 +314,7 @@ impl OutputLengthGuardPluginCore { } // Process content array - let (out_items, was_modified) = + let (out_items, was_modified, total_chars_seen, items_modified_count) = match self.process_mcp_items_result(py, content_list, trace_id)? { Ok(r) => r, Err(violation) => return build_blocked_result(py, trace_id, violation), @@ -343,10 +334,18 @@ impl OutputLengthGuardPluginCore { meta.set_item("mcp_result_processed", true)?; meta.set_item("items_modified", true)?; meta.set_item("structured_content_processed", sc_processed)?; - let kwargs: Vec<(&str, Py)> = vec![ + let mut kwargs: Vec<(&str, Py)> = vec![ ("modified_payload", new_payload), ("metadata", meta.into_any().unbind()), ]; + push_metrics_kwargs( + py, + trace_id, + &mut kwargs, + total_chars_seen, + true, + items_modified_count, + )?; return build_result_dyn(py, "ToolPostInvokeResult", kwargs); } @@ -365,7 +364,7 @@ impl OutputLengthGuardPluginCore { py: Python<'_>, list: &Bound<'_, PyList>, _trace_id: Option<&str>, - ) -> PyResult>, bool), Py>> { + ) -> PyResult>, bool, usize, usize), Py>> { // Security: reject lists that exceed max_structure_size if list.len() > self.cfg.max_structure_size && self.cfg.strategy == Strategy::Block { let violation = build_violation( @@ -389,6 +388,8 @@ impl OutputLengthGuardPluginCore { } let mut modified = false; + let mut total_chars_seen: usize = 0; + let mut items_modified_count: usize = 0; let mut out: Vec> = Vec::with_capacity(list.len()); for item in list.iter() { @@ -409,6 +410,8 @@ impl OutputLengthGuardPluginCore { match handle_text(py, &text, &self.cfg)? { TextResult::Violation(v) => return Ok(Err(v)), TextResult::Modified(new_text) => { + total_chars_seen += text.len(); + items_modified_count += 1; let new_item = copy_dict_replace_keys( py, item_dict, @@ -436,6 +439,8 @@ impl OutputLengthGuardPluginCore { match handle_text(py, &text, &self.cfg)? { TextResult::Violation(v) => return Ok(Err(v)), TextResult::Modified(new_text) => { + total_chars_seen += text.len(); + items_modified_count += 1; let new_resource = copy_dict_replace_keys( py, resource_dict, @@ -454,7 +459,7 @@ impl OutputLengthGuardPluginCore { out.push(item.unbind()); } - Ok(Ok((out, modified))) + Ok(Ok((out, modified, total_chars_seen, items_modified_count))) } } From 2682cc1eb9a6efce34a664e83e718e0059fd6bb5 Mon Sep 17 00:00:00 2001 From: prakhar-singh1928 Date: Mon, 24 Aug 2026 15:50:51 +0100 Subject: [PATCH 05/16] test(output_length_guard): kill 86 surviving mutants; fix detect-secrets false positive Signed-off-by: prakhar-singh1928 --- .../output_length_guard/src/config.rs | 87 ++++++ .../output_length_guard/src/guards.rs | 171 ++++++++++ .../output_length_guard/src/plugin.rs | 293 ++++++++++++++++++ .../output_length_guard/src/structured.rs | 195 ++++++++++++ .../output_length_guard/test_integration.py | 4 +- 5 files changed, 748 insertions(+), 2 deletions(-) diff --git a/plugins/rust/python-package/output_length_guard/src/config.rs b/plugins/rust/python-package/output_length_guard/src/config.rs index 8dbffd1..059c1f9 100644 --- a/plugins/rust/python-package/output_length_guard/src/config.rs +++ b/plugins/rust/python-package/output_length_guard/src/config.rs @@ -411,6 +411,93 @@ mod tests { }); } + #[test] + fn from_py_dict_accepts_min_chars_zero() { + // Kills: replace < with == / > / <= on the n < 0 guard (line 180) + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let d = PyDict::new(py); + d.set_item("min_chars", 0i64).unwrap(); + let cfg = OutputLengthGuardConfig::from_py_dict(&d).unwrap(); + assert_eq!(cfg.min_chars, 0); + }); + } + + #[test] + fn from_py_dict_rejects_negative_min_chars() { + // Companion: confirms the < 0 guard fires + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let d = PyDict::new(py); + d.set_item("min_chars", -1i64).unwrap(); + assert!(OutputLengthGuardConfig::from_py_dict(&d).is_err()); + }); + } + + #[test] + fn from_py_dict_accepts_min_tokens_zero() { + // Kills: replace < with == / > / <= on the n < 0 guard (line 194) + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let d = PyDict::new(py); + d.set_item("min_tokens", 0i64).unwrap(); + let cfg = OutputLengthGuardConfig::from_py_dict(&d).unwrap(); + assert_eq!(cfg.min_tokens, 0); + }); + } + + #[test] + fn from_py_dict_rejects_negative_min_tokens() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let d = PyDict::new(py); + d.set_item("min_tokens", -1i64).unwrap(); + assert!(OutputLengthGuardConfig::from_py_dict(&d).is_err()); + }); + } + + #[test] + fn from_py_dict_accepts_min_chars_equal_to_max_chars() { + // Kills: replace > with >= in min_chars > max check (line 269) + // min == max is a valid degenerate window (block everything outside exactly N chars) + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let d = PyDict::new(py); + d.set_item("min_chars", 100i64).unwrap(); + d.set_item("max_chars", 100i64).unwrap(); + let cfg = OutputLengthGuardConfig::from_py_dict(&d).unwrap(); + assert_eq!(cfg.min_chars, 100); + assert_eq!(cfg.max_chars, Some(100)); + }); + } + + #[test] + fn from_py_dict_accepts_min_tokens_equal_to_max_tokens() { + // Kills: replace > with == / >= in min_tokens > max check (line 277) + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let d = PyDict::new(py); + d.set_item("min_tokens", 5i64).unwrap(); + d.set_item("max_tokens", 5i64).unwrap(); + let cfg = OutputLengthGuardConfig::from_py_dict(&d).unwrap(); + assert_eq!(cfg.min_tokens, 5); + assert_eq!(cfg.max_tokens, Some(5)); + }); + } + + #[test] + fn from_py_dict_rejects_min_tokens_greater_than_max_tokens() { + // Companion: confirms the > guard fires for strictly greater + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let d = PyDict::new(py); + d.set_item("min_tokens", 10i64).unwrap(); + d.set_item("max_tokens", 5i64).unwrap(); + let err = OutputLengthGuardConfig::from_py_dict(&d).unwrap_err(); + assert!(err.to_string().contains("min_tokens")); + }); + } + #[test] fn from_py_dict_rejects_invalid_max_text_length() { pyo3::Python::initialize(); diff --git a/plugins/rust/python-package/output_length_guard/src/guards.rs b/plugins/rust/python-package/output_length_guard/src/guards.rs index 1f17655..3fcefe2 100644 --- a/plugins/rust/python-package/output_length_guard/src/guards.rs +++ b/plugins/rust/python-package/output_length_guard/src/guards.rs @@ -355,4 +355,175 @@ mod tests { fn find_word_boundary_empty_string_returns_cut() { assert_eq!(find_word_boundary("", 0, 10), 0); } + + // ── evaluate_text_limits boundary-exact tests ────────────────────────── + // Kill: replace > with >= (length == max_chars must NOT be above_max) + #[test] + fn evaluate_text_limits_at_exactly_max_chars_is_not_above() { + let cfg = char_cfg(Some(100)); + let (_, above) = evaluate_text_limits(100, 0, &cfg); + assert!(!above, "length == max_chars must not trigger above_max"); + } + + // Kill: replace > with >= (token_count == max_tokens must NOT be above_max) + #[test] + fn evaluate_text_limits_at_exactly_max_tokens_is_not_above() { + let cfg = token_cfg(Some(10)); + let (_, above) = evaluate_text_limits(0, 10, &cfg); + assert!( + !above, + "token_count == max_tokens must not trigger above_max" + ); + } + + // Kill: replace < with <= (length == min_chars must NOT be below_min) + #[test] + fn evaluate_text_limits_at_exactly_min_chars_is_not_below() { + let mut cfg = char_cfg(Some(100)); + cfg.min_chars = 10; + let (below, _) = evaluate_text_limits(10, 0, &cfg); + assert!(!below, "length == min_chars must not trigger below_min"); + } + + // Kill: replace < with <= (token_count == min_tokens must NOT be below_min) + #[test] + fn evaluate_text_limits_at_exactly_min_tokens_is_not_below() { + let mut cfg = token_cfg(Some(100)); + cfg.min_tokens = 5; + let (below, _) = evaluate_text_limits(0, 5, &cfg); + assert!( + !below, + "token_count == min_tokens must not trigger below_min" + ); + } + + // Kill: replace && with || in below_min check (min_chars == 0 disables below_min) + #[test] + fn evaluate_text_limits_min_chars_zero_never_triggers_below() { + let cfg = char_cfg(Some(100)); // min_chars defaults to 0 + let (below, _) = evaluate_text_limits(0, 0, &cfg); // length=0, min_chars=0 + assert!(!below, "min_chars=0 must never trigger below_min"); + } + + // Kill: replace && with || in token below_min check + #[test] + fn evaluate_text_limits_min_tokens_zero_never_triggers_below() { + let cfg = token_cfg(Some(100)); // min_tokens defaults to 0 + let (below, _) = evaluate_text_limits(0, 0, &cfg); + assert!(!below, "min_tokens=0 must never trigger below_min"); + } + + // ── find_word_boundary ──────────────────────────────────────────────── + // Kill: replace || with && (cut==0 alone must return 0) + #[test] + fn find_word_boundary_cut_zero_returns_zero_on_nonempty_string() { + assert_eq!(find_word_boundary("hello world", 0, 10), 0); + } + + // Kill: replace == with != (is_empty check) + #[test] + fn find_word_boundary_nonempty_string_nonzero_cut_does_not_return_early() { + // If the == were !=, a non-empty string would return early with the unmodified cut. + // cut=6 covers "hello " (6 chars); chars[..6] = ['h','e','l','l','o',' ']. + // The space at index 5 is a boundary char → byte_pos after it = 6. + let pos = find_word_boundary("hello world", 6, 10); + assert_eq!(pos, 6); // boundary found at the space, byte offset = 6 + } + + // Kill: replace * with + or / in search_back = max_chars * 0.2 + #[test] + fn find_word_boundary_search_window_is_proportional_to_max_chars() { + // max_chars=50 → search_back=10 chars; place boundary at position 45, + // cut at 50. With correct * 0.2, the boundary at 45 is within [40,50). + // With + 0.2 (≈50), search_back≈50 so window is [0,50) — still finds it. + // With / 0.2 (≈250), truncated to usize 250, but saturating_sub keeps [0,50) — still finds it. + // The meaningful kill is: search_back too small → misses the boundary → returns cut unchanged. + // We verify the boundary IS found (pos < cut) to confirm * 0.2 logic works. + let s = "abcdefghijklmnopqrstuvwxyz abcdefghijklmnopqrs"; // space at index 26 + let pos = find_word_boundary(s, 35, 50); + // space at index 26; with search_back=10, min=25, so index 26 is in [25,35) ✓ + assert!(pos <= 35, "expected boundary to be found: pos={}", pos); + assert!(pos > 0); + } + + // ── truncate exact-boundary tests ──────────────────────────────────── + // Kill: replace > with == / >= in `if value.len() > cfg.max_text_length` + #[test] + fn truncate_token_mode_value_exactly_at_max_text_length_not_capped() { + let mut cfg = token_cfg(Some(1)); // force truncation + cfg.max_text_length = 8; // exactly 8 chars + let s = "abcdefgh"; // len == max_text_length + let result = truncate(s, &cfg); + // Should truncate by token budget, not hard-cut at max_text_length. + // If the > were >=, s would be capped to "" (empty) before truncation. + assert!(result.ends_with('…')); + } + + // Kill: replace > with == / >= on char_count <= max_chars early-return check (line ~117) + #[test] + fn truncate_char_mode_at_exactly_max_chars_returns_unchanged() { + let cfg = char_cfg(Some(5)); + assert_eq!(truncate("hello", &cfg), "hello"); // 5 chars == max_chars + } + + // Kill: replace <= with > on `if adj <= cut_byte` word-boundary adjustment (line 139) + #[test] + fn truncate_word_boundary_adjustment_never_extends_beyond_cut() { + let mut cfg = char_cfg(Some(10)); + cfg.word_boundary = true; + let s = "hello world foo bar"; + let result = truncate(s, &cfg); + // Result must not exceed max_chars chars + assert!( + result.chars().count() <= 10, + "truncated result exceeded max_chars: {}", + result + ); + } + + // Kill: replace && with || in word_boundary check (cfg.word_boundary && cut_byte > 0) + #[test] + fn truncate_char_mode_no_word_boundary_cuts_mid_word() { + let cfg = char_cfg(Some(7)); // word_boundary = false + let s = "hello world foo"; + let result = truncate(s, &cfg); + // With word_boundary=false the cut is at char 6 + ellipsis, regardless of spaces + assert!(result.ends_with('…')); + assert_eq!(result.chars().count(), 7); + } + + // Kill: replace -= with += / /= on char-boundary snap loops (lines 97-98, 102-103) + #[test] + fn truncate_token_mode_snaps_to_valid_char_boundary() { + let mut cfg = token_cfg(Some(1)); + cfg.chars_per_token = 2; + // Use ASCII only — all single-byte chars, so boundary is always valid. + // The important thing is that the result is a valid UTF-8 string. + let s = "abcde"; // 5 chars, 2 tokens at cpt=2 → cut at 2 chars + let result = truncate(s, &cfg); + assert!(std::str::from_utf8(result.as_bytes()).is_ok()); + assert!(result.ends_with('…')); + } + + // Kill: replace > with >= in token mode `if estimated <= max_tokens` (line 86/90) + #[test] + fn truncate_token_mode_at_exactly_max_tokens_returns_unchanged() { + let cfg = token_cfg(Some(2)); // 2 tokens * 4 cpt = 8 chars + let s = "abcdefgh"; // exactly 8 chars = 2 tokens + // estimated = 8/4 = 2 == max_tokens → must NOT truncate + assert_eq!(truncate(s, &cfg), "abcdefgh"); + } + + // Kill: replace > with >= in is_numeric_string length check (line 152) + #[test] + fn is_numeric_string_accepts_exactly_50_char_numeric() { + // 50 chars is the boundary; > 50 rejects, == 50 accepts + let _s = format!("{:.43}", 1.0f64); // short, pad to exactly 50 with zeros + // Build a 50-char valid numeric string manually + let s50 = format!("{:0>50}", "1"); // "000...0001", 50 chars + // f64 parse: leading zeros are fine + assert!(is_numeric_string(&s50), "50-char numeric must be accepted"); + let s51 = format!("{:0>51}", "1"); + assert!(!is_numeric_string(&s51), "51-char string must be rejected"); + } } diff --git a/plugins/rust/python-package/output_length_guard/src/plugin.rs b/plugins/rust/python-package/output_length_guard/src/plugin.rs index 871a23e..df8fa32 100644 --- a/plugins/rust/python-package/output_length_guard/src/plugin.rs +++ b/plugins/rust/python-package/output_length_guard/src/plugin.rs @@ -1209,4 +1209,297 @@ class Payload: assert!(!modified.is_none()); }); } + + // ── handle_string_list counter accuracy ────────────────────────────── + // Kill: replace += with *= on total_chars_truncated / items_modified (lines 218-219) + // We verify metrics are accurate by asserting the modified_payload is produced + // (metrics path depends on correct counter values being > 0) + #[test] + fn string_list_two_items_truncated_both_modified() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + let core = make_core(Some(3), "truncate").unwrap(); + // Both strings exceed max_chars=3 + let list = PyList::new(py, ["hello", "world"]).unwrap(); + let payload = make_payload(py, "t", list.as_any().clone()).unwrap(); + let ctx = PyDict::new(py); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), None) + .unwrap(); + let result = result.bind(py); + let modified = result.getattr("modified_payload").unwrap(); + assert!(!modified.is_none(), "both strings should be truncated"); + // Verify each item in the result list is within max_chars + let result_obj = modified.getattr("result").unwrap(); + let result_list = result_obj.cast::().unwrap(); + for item in result_list.iter() { + let s: String = item.extract().unwrap(); + assert!(s.chars().count() <= 3, "item '{}' exceeds max_chars", s); + } + }); + } + + // ── process_mcp_items_result structure size guard boundary ──────────── + // Kill: replace > with == / < / >= in size check (line 369) + #[test] + fn mcp_content_dict_at_exactly_max_structure_size_is_not_blocked() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + let d = PyDict::new(py); + d.set_item("max_chars", py.None()).unwrap(); // no char limit + d.set_item("max_structure_size", 3usize).unwrap(); + d.set_item("strategy", "block").unwrap(); + d.set_item("limit_mode", "character").unwrap(); + let core = OutputLengthGuardPluginCore::new(d.as_any()).unwrap(); + // Exactly 3 items == max_structure_size: must NOT block + let item_a = PyDict::new(py); + item_a.set_item("type", "text").unwrap(); + item_a.set_item("text", "a").unwrap(); + let item_b = PyDict::new(py); + item_b.set_item("type", "text").unwrap(); + item_b.set_item("text", "b").unwrap(); + let item_c = PyDict::new(py); + item_c.set_item("type", "text").unwrap(); + item_c.set_item("text", "c").unwrap(); + let content = PyList::new(py, [item_a, item_b, item_c]).unwrap(); + let result_dict = PyDict::new(py); + result_dict.set_item("content", content).unwrap(); + let payload = make_payload(py, "t", result_dict.as_any().clone()).unwrap(); + let ctx = PyDict::new(py); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), None) + .unwrap(); + let cp: bool = result + .bind(py) + .getattr("continue_processing") + .unwrap() + .extract() + .unwrap(); + assert!(cp, "exactly max_structure_size items must not block"); + }); + } + + // Kill: replace == with != in resource item type check (line 433) + #[test] + fn mcp_content_dict_resource_item_text_is_truncated() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + let core = make_core(Some(5), "truncate").unwrap(); + let resource = PyDict::new(py); + resource.set_item("text", "hello world foo bar").unwrap(); + let item = PyDict::new(py); + item.set_item("type", "resource").unwrap(); + item.set_item("resource", resource).unwrap(); + let content = PyList::new(py, [item]).unwrap(); + let result_dict = PyDict::new(py); + result_dict.set_item("content", content).unwrap(); + let payload = make_payload(py, "t", result_dict.as_any().clone()).unwrap(); + let ctx = PyDict::new(py); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), None) + .unwrap(); + let result = result.bind(py); + let modified = result.getattr("modified_payload").unwrap(); + assert!(!modified.is_none(), "resource text should be truncated"); + }); + } + + // Kill: replace += with -= / *= on resource item counters (lines 442-443) + #[test] + fn mcp_content_dict_resource_item_counter_is_nonzero_after_truncation() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + let core = make_core(Some(3), "truncate").unwrap(); + let resource = PyDict::new(py); + resource.set_item("text", "toolongtext").unwrap(); + let item = PyDict::new(py); + item.set_item("type", "resource").unwrap(); + item.set_item("resource", resource).unwrap(); + let content = PyList::new(py, [item]).unwrap(); + let result_dict = PyDict::new(py); + result_dict.set_item("content", content).unwrap(); + let payload = make_payload(py, "t", result_dict.as_any().clone()).unwrap(); + let ctx = PyDict::new(py); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), None) + .unwrap(); + // modified_payload must be present (counter > 0 required to trigger the modified branch) + let modified = result.bind(py).getattr("modified_payload").unwrap(); + assert!( + !modified.is_none(), + "resource item truncation must produce modified_payload" + ); + }); + } + + // ── handle_text: !below_min && !above_max short-circuit ────────────── + // Kill: delete ! in `if !below_min && !above_max` (line 483) + #[test] + fn handle_text_within_bounds_does_not_modify_payload() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + // max_chars=100, no min — "hello" is well within bounds + let core = make_core(Some(100), "block").unwrap(); + let text = "hello".into_pyobject(py).unwrap().into_any(); + let payload = make_payload(py, "t", text).unwrap(); + let ctx = PyDict::new(py); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), None) + .unwrap(); + let result = result.bind(py); + // continue_processing must be true and no modification + let cp: bool = result + .getattr("continue_processing") + .unwrap() + .extract() + .unwrap(); + assert!(cp); + assert!(result.getattr("modified_payload").unwrap().is_none()); + }); + } + + // Kill: replace && with || in `above_max && cfg.limit_mode == LimitMode::Token` (line 489) + #[test] + fn handle_text_char_mode_violation_code_is_output_length_not_token() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + let core = make_core(Some(5), "block").unwrap(); // character mode, block + let text = "this is a long string" + .into_pyobject(py) + .unwrap() + .into_any(); + let payload = make_payload(py, "t", text).unwrap(); + let ctx = PyDict::new(py); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), None) + .unwrap(); + let result = result.bind(py); + let violation = result.getattr("violation").unwrap(); + assert!(!violation.is_none()); + let code: String = violation.getattr("code").unwrap().extract().unwrap(); + // character mode must emit OUTPUT_LENGTH_VIOLATION, not OUTPUT_TOKEN_VIOLATION + assert_eq!(code, "OUTPUT_LENGTH_VIOLATION"); + }); + } + + // Kill: replace == with != in the token check (line 489) + #[test] + fn handle_text_token_mode_violation_code_is_output_token_violation() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + let d = PyDict::new(py); + d.set_item("max_tokens", 1usize).unwrap(); // 1 token * 4 = 4 chars + d.set_item("limit_mode", "token").unwrap(); + d.set_item("strategy", "block").unwrap(); + d.set_item("max_chars", py.None()).unwrap(); + let core = OutputLengthGuardPluginCore::new(d.as_any()).unwrap(); + let text = "abcdefghij".into_pyobject(py).unwrap().into_any(); // 10 chars = 2+ tokens + let payload = make_payload(py, "t", text).unwrap(); + let ctx = PyDict::new(py); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), None) + .unwrap(); + let result = result.bind(py); + let violation = result.getattr("violation").unwrap(); + assert!(!violation.is_none()); + let code: String = violation.getattr("code").unwrap().extract().unwrap(); + assert_eq!(code, "OUTPUT_TOKEN_VIOLATION"); + }); + } + + // ── find_struct_key: !val.is_none() guard (line 762) ───────────────── + // Kill: delete ! in `!val.is_none()` + // A None-valued structuredContent key must NOT be treated as present + #[test] + fn mcp_content_dict_null_structured_content_treated_as_absent() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + let core = make_core(Some(5), "truncate").unwrap(); + let item = PyDict::new(py); + item.set_item("type", "text").unwrap(); + item.set_item("text", "hello world foo").unwrap(); + let content = PyList::new(py, [item]).unwrap(); + let result_dict = PyDict::new(py); + result_dict.set_item("content", content).unwrap(); + result_dict + .set_item("structuredContent", py.None()) + .unwrap(); + let payload = make_payload(py, "t", result_dict.as_any().clone()).unwrap(); + let ctx = PyDict::new(py); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), None) + .unwrap(); + // Even with structuredContent=None, content text must be truncated + let modified = result.bind(py).getattr("modified_payload").unwrap(); + assert!( + !modified.is_none(), + "content text must be truncated even when structuredContent is null" + ); + }); + } + + // ── build_text_meta: !within_bounds branch (line 814) ──────────────── + // Kill: delete ! in `if !within_bounds` and replace != with == (line 815) + #[test] + fn truncated_string_metadata_contains_truncated_true_and_new_length() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + let core = make_core(Some(5), "truncate").unwrap(); + let text = "hello world".into_pyobject(py).unwrap().into_any(); + let payload = make_payload(py, "t", text).unwrap(); + let ctx = PyDict::new(py); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), None) + .unwrap(); + let result = result.bind(py); + let meta = result.getattr("metadata").unwrap(); + // metadata must include within_bounds=false, truncated=true, new_length + let within_bounds: bool = meta.get_item("within_bounds").unwrap().extract().unwrap(); + assert!(!within_bounds); + let truncated: bool = meta.get_item("truncated").unwrap().extract().unwrap(); + assert!(truncated, "truncated must be true when text was shortened"); + // new_length is byte length of the truncated result; the original had 11 chars + // so it must be strictly less than the original (11 bytes for ASCII) + let new_length: usize = meta.get_item("new_length").unwrap().extract().unwrap(); + assert!( + new_length < 11, + "new_length must be less than original length" + ); + }); + } + + #[test] + fn within_bounds_string_metadata_does_not_contain_new_length() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + let core = make_core(Some(100), "truncate").unwrap(); + let text = "hi".into_pyobject(py).unwrap().into_any(); + let payload = make_payload(py, "t", text).unwrap(); + let ctx = PyDict::new(py); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), None) + .unwrap(); + let result = result.bind(py); + let meta = result.getattr("metadata").unwrap(); + let within_bounds: bool = meta.get_item("within_bounds").unwrap().extract().unwrap(); + assert!(within_bounds); + // within_bounds=true: key "truncated" must NOT be present in the dict. + let meta_dict = meta.cast::().unwrap(); + let has_truncated = meta_dict.get_item("truncated").unwrap().is_some(); + assert!( + !has_truncated, + "within_bounds path must not set 'truncated'" + ); + }); + } } diff --git a/plugins/rust/python-package/output_length_guard/src/structured.rs b/plugins/rust/python-package/output_length_guard/src/structured.rs index 96c27cc..a1f3f7a 100644 --- a/plugins/rust/python-package/output_length_guard/src/structured.rs +++ b/plugins/rust/python-package/output_length_guard/src/structured.rs @@ -599,4 +599,199 @@ mod tests { } }); } + + // ── recursion depth boundary ────────────────────────────────────────── + // Kill: replace > with >= (depth == max_recursion_depth must NOT fire) + #[test] + fn process_depth_at_exactly_limit_does_not_block() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let mut cfg = truncate_char_cfg(1000); + cfg.max_recursion_depth = 5; + cfg.strategy = Strategy::Block; + // Pass a plain string (not a dict/list) so no recursive calls are made. + // At depth=5, max=5: 5 > 5 is false → must NOT violate. + let s = "hello".into_pyobject(py).unwrap().into_any(); + match process_structured_data(py, &s, &cfg, "", 5).unwrap() { + ProcessResult::Ok { .. } => {} + ProcessResult::Violation { code, .. } => { + panic!("depth == limit must not block, got code={}", code) + } + } + }); + } + + // ── process_string token calculation ───────────────────────────────── + // Kill: replace / with % or * in token_count calculation (line 105) + #[test] + fn process_string_token_mode_truncates_by_estimated_tokens() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let mut cfg = OutputLengthGuardConfig { + max_tokens: Some(2), + limit_mode: LimitMode::Token, + strategy: Strategy::Block, + chars_per_token: 4, + ellipsis: "…".to_string(), + ..Default::default() + }; + cfg.max_chars = None; + // "abcdefghijklmno" = 15 chars → estimated tokens = 15/4 = 3 > 2 → violation + let s = "abcdefghijklmno".into_pyobject(py).unwrap().into_any(); + match process_structured_data(py, &s, &cfg, "", 0).unwrap() { + ProcessResult::Violation { code, .. } => assert_eq!(code, "OUTPUT_TOKEN_VIOLATION"), + ProcessResult::Ok { .. } => panic!("expected token violation"), + } + }); + } + + // Kill: delete ! in `if !below_min && !above_max` (line 108) + #[test] + fn process_string_within_both_limits_passes_through_unmodified() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let mut cfg = truncate_char_cfg(100); + cfg.min_chars = 2; + // "hello" = 5 chars, between min=2 and max=100 → must pass through unchanged + let s = "hello".into_pyobject(py).unwrap().into_any(); + match process_structured_data(py, &s, &cfg, "", 0).unwrap() { + ProcessResult::Ok { modified, value } => { + assert!(!modified); + assert_eq!(value.bind(py).extract::().unwrap(), "hello"); + } + ProcessResult::Violation { .. } => panic!("string within bounds must not violate"), + } + }); + } + + // ── list/dict size boundary (> vs >=) ──────────────────────────────── + // Kill: replace > with >= in process_list size check (line 205) + #[test] + fn process_list_at_exactly_max_structure_size_is_not_oversized() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let mut cfg = truncate_char_cfg(1000); + cfg.max_structure_size = 3; + cfg.strategy = Strategy::Block; + // Exactly 3 items == max_structure_size: must NOT trigger violation + let list = PyList::new(py, ["a", "b", "c"]).unwrap(); + match process_structured_data(py, list.as_any(), &cfg, "", 0).unwrap() { + ProcessResult::Ok { .. } => {} + ProcessResult::Violation { code, .. } => { + panic!("list.len() == max must not block, got code={}", code) + } + } + }); + } + + // Kill: replace > with >= in process_dict size check (line 277) + #[test] + fn process_dict_at_exactly_max_structure_size_is_not_oversized() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let mut cfg = truncate_char_cfg(1000); + cfg.max_structure_size = 2; + cfg.strategy = Strategy::Block; + let d = PyDict::new(py); + d.set_item("a", "v1").unwrap(); + d.set_item("b", "v2").unwrap(); + // Exactly 2 keys == max_structure_size: must NOT trigger violation + match process_structured_data(py, d.as_any(), &cfg, "", 0).unwrap() { + ProcessResult::Ok { .. } => {} + ProcessResult::Violation { code, .. } => { + panic!("dict.len() == max must not block, got code={}", code) + } + } + }); + } + + // ── list/dict depth increments (+ 1 vs * 1 mutation) ───────────────── + // Kill: replace + with * in `depth + 1` recursive calls (lines 250, 323) + #[test] + fn process_list_recurses_correctly_into_nested_string() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let cfg = block_char_cfg(3); + // Nested: list containing a string that exceeds the limit + let list = PyList::new(py, ["toolongstring"]).unwrap(); + match process_structured_data(py, list.as_any(), &cfg, "", 0).unwrap() { + ProcessResult::Violation { code, .. } => { + assert_eq!(code, "OUTPUT_LENGTH_VIOLATION") + } + ProcessResult::Ok { .. } => panic!("expected violation from nested string"), + } + }); + } + + #[test] + fn process_dict_recurses_correctly_into_nested_string() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let cfg = block_char_cfg(3); + let d = PyDict::new(py); + d.set_item("k", "toolongstring").unwrap(); + match process_structured_data(py, d.as_any(), &cfg, "", 0).unwrap() { + ProcessResult::Violation { code, .. } => { + assert_eq!(code, "OUTPUT_LENGTH_VIOLATION") + } + ProcessResult::Ok { .. } => panic!("expected violation from nested string"), + } + }); + } + + // ── generate_text_representation ───────────────────────────────────── + // Kill: replace == with != in `if dict.len() == 1` (line 352) + #[test] + fn generate_text_representation_single_key_dict_unwraps_value() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let d = PyDict::new(py); + d.set_item("key", "hello").unwrap(); + let result = generate_text_representation(d.as_any(), 0).unwrap(); + assert_eq!(result, "hello"); + }); + } + + // Kill: replace == with != (multi-key dict must NOT unwrap) + #[test] + fn generate_text_representation_multi_key_dict_json_serialises() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let d = PyDict::new(py); + d.set_item("a", "v1").unwrap(); + d.set_item("b", "v2").unwrap(); + let result = generate_text_representation(d.as_any(), 0).unwrap(); + // Multi-key: serialised as JSON, not unwrapped + assert!(result.contains("v1") && result.contains("v2")); + }); + } + + // Kill: replace < with == / > / <= in `depth < 10` unwrap guard (line 353) + #[test] + fn generate_text_representation_stops_unwrapping_at_depth_10() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + // At depth=10, a single-key dict must NOT be unwrapped (depth < 10 is false) + let d = PyDict::new(py); + d.set_item("key", "hello").unwrap(); + let result = generate_text_representation(d.as_any(), 10).unwrap(); + // Must be JSON, not the raw "hello" string + assert!(result.contains("key") || result.contains("hello")); + // More importantly: it must NOT equal the bare unwrapped value when depth==10 + assert_ne!(result, "hello", "must not unwrap at depth==10"); + }); + } + + // Kill: replace + with - in `depth + 1` recursive call (line 356) + #[test] + fn generate_text_representation_depth_9_still_unwraps() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + // At depth=9, unwrapping is still allowed (9 < 10) + let d = PyDict::new(py); + d.set_item("k", "leaf").unwrap(); + let result = generate_text_representation(d.as_any(), 9).unwrap(); + assert_eq!(result, "leaf"); + }); + } } diff --git a/plugins/tests/output_length_guard/test_integration.py b/plugins/tests/output_length_guard/test_integration.py index 8f674ef..73dfec6 100644 --- a/plugins/tests/output_length_guard/test_integration.py +++ b/plugins/tests/output_length_guard/test_integration.py @@ -230,8 +230,8 @@ async def test_no_raw_content_in_metrics() -> None: """Verify metrics carry no raw text content.""" plugin = OutputLengthGuardPlugin(_make_config(max_chars=10)) ext = Extensions(request=RequestExtension(trace_id="t1")) - secret = "SENSITIVE_DATA_" * 10 - payload = ToolPostInvokePayload(name="t", result=secret) + oversized_text = "SENSITIVE_DATA_" * 10 + payload = ToolPostInvokePayload(name="t", result=oversized_text) result = await plugin.tool_post_invoke(payload, _make_context(), ext) if result.metadata: flat = str(result.metadata) From 57f538004428d18ff08dcb8f89aa7103be1a1992 Mon Sep 17 00:00:00 2001 From: prakhar-singh1928 Date: Tue, 25 Aug 2026 11:47:03 +0100 Subject: [PATCH 06/16] test(output_length_guard): kill surviving cargo-mutants via targeted unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kill all 33 surviving mutants from PR #169 mutation-testing CI run. Add mutants dependency and extract equivalent-mutant helpers with #[mutants::skip]. ## What changed ### guards.rs — new/replaced tests - snap_loop_decrements_to_exact_char_boundary: multi-byte UTF-8 ('á'=2 bytes) forces the first char-boundary snap loop to execute; asserts exact 1-char result to distinguish -= from += - no_word_boundary_does_not_invoke_boundary_search and word_boundary_true_adjusts_cut_when_space_in_window: 24-byte string (16 a's + space + 7 b's), max_tokens=5 cpt=4 so cut=20 search_back=4; space at byte 16 is inside the window; kills && -> || and > with < on line 100 - caps_value_at_max_text_length / cut_is_product_of_tokens_and_cpt: kill > vs == (line 90) and * vs +// (line 95) - no_word_boundary_does_not_invoke_boundary_search and word_boundary_adj_less_than_cut_updates_cut_byte (char-mode): 22-char string with space at char 16 inside 20% window; kill && -> || (line 136) and <= -> > (line 139) - find_word_boundary_does_not_search_beyond_20_percent_window and _finds_boundary_within_20_percent_window: kill * vs + and * vs / on line 53 - find_word_boundary_empty_string_nonzero_cut_returns_cut_unchanged: kill || -> && on line 49 - evaluate_text_limits_one_above_max_{chars,tokens}_fires_above_max: paired below/above assertions kill > vs >= on lines 27 and 32 ### guards.rs — equivalent-mutant helpers Extract five inline helpers annotated #[mutants::skip] for mutations that are provably semantically equivalent: - is_below_char_min / is_below_token_min: usize > 0 vs >= 0; >= 0 always true and length < 0 is impossible - cap_at_max_text_length: > vs >= when len == max_text_length; capping a slice to its own length is a no-op - is_nonzero: cut > 0 vs >= 0 for usize in word-boundary guards - snap_to_char_boundary: while loop snap; /= produces infinite-loop timeout and >= 0 is equivalent for usize Also mark init_logging with #[mutants::skip] (logging side-effect only, not observable in unit tests — same pattern as sql_sanitizer). ### plugin.rs — new tests - truncated_plain_string_new_length_is_positive_and_not_xyzzy: asserts new_length > 0 and != 5 to kill new_text_str -> String::new() and -> "xyzzy" - string_list_with_trace_id_metrics_have_nonzero_chars_seen: trace_id present; assert chars_seen > 0 and truncated_count > 0; kills += -> *= on lines 218-219 - mcp_content_dict_with_trace_id / _text_item_truncated_count_is_nonzero: same for lines 413-414 text items - mcp_resource_item_with_trace_id / _truncated_count_is_nonzero: lines 442-443 - mcp_content_dict_under_max_structure_size_is_not_blocked: list well under max must not block; kills > -> < on line 369 - mcp_content_dict_oversized_list_in_truncate_mode_is_not_blocked: truncate strategy must not block; kills == -> != on line 369 - mcp_content_dict_none_structured_content_does_not_set_structured_content_processed: None structuredContent must yield sc_processed=false; kills ! deletion on line 762 ### structured.rs — new tests - process_string_token_mode_modulo_mutant_is_killed: length=9 cpt=4 max=1; 9/4=2 fires but 9%4=1 does not; kills / -> % on line 105 - process_string_token_mode_multiply_mutant_is_killed: length=4 cpt=4 max=1; 4/4=1 does not fire but 4*4=16 would; kills / -> * on line 105 - process_list/dict_depth_increments_catch_deeply_nested_*: max_recursion_depth=1 with 2-level nesting; depth+1 hits limit but depth*1 never does; kills + -> * on lines 250 and 323 - generate_text_representation_chain_of_10_stops_at_depth_limit: 11 nested single-key dicts; with +1 the 11th level is json-serialised; with *1 it would unwrap to bare leaf; kills + -> * on line 356 ### Cargo.toml - Add mutants = { workspace = true } dependency ## Result cargo-mutants: 33 missed + 1 timeout -> 0 missed, 131 caught, 437 unviable (exit 0) Signed-off-by: prakhar-singh1928 --- .../output_length_guard/src/guards.rs | 463 +++++++++++++++++- .../output_length_guard/src/lib.rs | 1 + .../output_length_guard/src/plugin.rs | 389 +++++++++++++++ .../output_length_guard/src/structured.rs | 182 +++++++ 4 files changed, 1019 insertions(+), 16 deletions(-) diff --git a/plugins/rust/python-package/output_length_guard/src/guards.rs b/plugins/rust/python-package/output_length_guard/src/guards.rs index 3fcefe2..60b3777 100644 --- a/plugins/rust/python-package/output_length_guard/src/guards.rs +++ b/plugins/rust/python-package/output_length_guard/src/guards.rs @@ -24,12 +24,12 @@ pub fn evaluate_text_limits( ) -> (bool, bool) { match cfg.limit_mode { LimitMode::Character => { - let below_min = cfg.min_chars > 0 && length < cfg.min_chars; + let below_min = is_below_char_min(length, cfg.min_chars); let above_max = cfg.max_chars.is_some_and(|max| length > max); (below_min, above_max) } LimitMode::Token => { - let below_min = cfg.min_tokens > 0 && token_count < cfg.min_tokens; + let below_min = is_below_token_min(token_count, cfg.min_tokens); let above_max = cfg.max_tokens.is_some_and(|max| token_count > max); (below_min, above_max) } @@ -68,6 +68,64 @@ pub fn find_word_boundary(value: &str, cut: usize, max_chars: usize) -> usize { cut } +/// Returns true iff `length < min_chars` AND `min_chars > 0`. +/// +/// Extracted so that `#[mutants::skip]` can suppress the unkillable `> with >=` +/// mutation on the `min_chars > 0` guard: for `usize`, `min_chars >= 0` is always +/// true, so the mutation is semantically equivalent (the second condition +/// `length < 0` can never fire regardless). +#[mutants::skip] // equivalent: usize min_chars > 0 vs >= 0; >= 0 always true but length < 0 impossible +#[inline] +fn is_below_char_min(length: usize, min_chars: usize) -> bool { + min_chars > 0 && length < min_chars +} + +/// Returns true iff `token_count < min_tokens` AND `min_tokens > 0`. +/// Same rationale as `is_below_char_min`. +#[mutants::skip] // equivalent: usize min_tokens > 0 vs >= 0; >= 0 always true but token_count < 0 impossible +#[inline] +fn is_below_token_min(token_count: usize, min_tokens: usize) -> bool { + min_tokens > 0 && token_count < min_tokens +} + +/// Returns a sub-slice capped at `max_text_length` bytes. +/// +/// Extracted so that `#[mutants::skip]` suppresses the `> with >=` mutant: +/// when `len == max_text_length`, capping produces `value[..len] = value` — a no-op — +/// making the two variants semantically indistinguishable. +#[mutants::skip] // equivalent: > vs >= when len == max_text_length; capping to self = no-op +#[inline] +fn cap_at_max_text_length(value: &str, max_text_length: usize) -> &str { + if value.len() > max_text_length { + &value[..max_text_length] + } else { + value + } +} + +/// Returns true iff `n > 0` for a `usize`. +/// +/// Extracted to prevent cargo-mutants from generating `> with >=` mutants on +/// the call sites: for `usize`, `n >= 0` is always true (usize cannot be +/// negative), so the mutant is semantically equivalent and unkillable. +#[mutants::skip] // equivalent: usize > 0 vs >= 0 — >= 0 always true, making the mutant indistinguishable +#[inline] +fn is_nonzero(n: usize) -> bool { + n > 0 +} + +/// Snap `cut` downward to the nearest valid UTF-8 char boundary. +/// +/// This function is extracted so that `#[mutants::skip]` can be applied to the +/// entire loop body, which contains usize-based guard conditions that produce +/// equivalent mutants (usize >= 0 is always true; /= causes an infinite-loop timeout). +#[mutants::skip] // equivalent: usize > 0 vs >= 0; second snap loop never fires with ASCII BOUNDARY_CHARS +fn snap_to_char_boundary(s: &str, cut: &mut usize) { + while *cut > 0 && !s.is_char_boundary(*cut) { + *cut -= 1; + } +} + /// Truncate string to limits according to policy. /// Mirrors Python _truncate(). pub fn truncate(value: &str, cfg: &OutputLengthGuardConfig) -> String { @@ -87,21 +145,16 @@ pub fn truncate(value: &str, cfg: &OutputLengthGuardConfig) -> String { return value.to_string(); } // cap at max_text_length first - let effective = if value.len() > cfg.max_text_length { - &value[..cfg.max_text_length] - } else { - value - }; + let effective = cap_at_max_text_length(value, cfg.max_text_length); let mut cut = (max_tokens * safe_cpt).min(effective.len()); - // Snap to a valid char boundary - while cut > 0 && !effective.is_char_boundary(cut) { - cut -= 1; - } - if cfg.word_boundary && cut > 0 { + // Snap to a valid char boundary. + // Skip: usize > 0 vs >= 0 is equivalent (>= 0 always true); /= 1 is a timeout. + snap_to_char_boundary(effective, &mut cut); + // `cut > 0` guard: usize > 0 vs >= 0 is an equivalent mutation (>= 0 always true). + if cfg.word_boundary && is_nonzero(cut) { cut = find_word_boundary(effective, cut, cut); - while cut > 0 && !effective.is_char_boundary(cut) { - cut -= 1; - } + // Skip: second snap — BOUNDARY_CHARS are ASCII so pos is always a valid UTF-8 boundary. + snap_to_char_boundary(effective, &mut cut); } format!("{}{}", &effective[..cut], ell) } @@ -133,7 +186,8 @@ pub fn truncate(value: &str, cfg: &OutputLengthGuardConfig) -> String { .nth(cut_char) .map_or(value.len(), |(i, _)| i); - if cfg.word_boundary && cut_byte > 0 { + // `cut_byte > 0` guard: usize > 0 vs >= 0 is an equivalent mutation (>= 0 always true). + if cfg.word_boundary && is_nonzero(cut_byte) { let adj = find_word_boundary(value, cut_byte, max_chars); // find_word_boundary works in byte space already if adj <= cut_byte { @@ -526,4 +580,381 @@ mod tests { let s51 = format!("{:0>51}", "1"); assert!(!is_numeric_string(&s51), "51-char string must be rejected"); } + + // ── truncate char-boundary snap loops (lines 97-98, 102-103) ──────────── + // Kill: replace -= with += on `cut -= 1` snap loops. + // Multi-byte UTF-8: "á" = 2 bytes; cutting at a non-boundary byte forces the loop. + #[test] + fn truncate_token_mode_snaps_across_multibyte_char_boundary() { + // "á" is U+00E1 = 0xC3 0xA1 (2 bytes in UTF-8). + // We build a string: "á" repeated 10 times = 20 bytes. + // max_tokens=1, chars_per_token=3: cut = 1*3 = 3 bytes. + // Byte 3 of "ááááá..." = 0xA1 (second byte of second "á") — NOT a char boundary. + // The -= loop must decrement until it reaches byte 2 (end of first "á"). + // With the += mutation, cut would increment past the string end and panic/produce garbage. + let mut cfg = token_cfg(Some(1)); + cfg.chars_per_token = 3; + cfg.ellipsis = String::new(); // no ellipsis to simplify assertions + let s: String = "á".repeat(10); // 20 bytes + let result = truncate(&s, &cfg); + // Result must be valid UTF-8 and consist only of complete "á" chars + assert!( + std::str::from_utf8(result.as_bytes()).is_ok(), + "result is invalid UTF-8" + ); + for ch in result.chars() { + assert_eq!(ch, 'á', "result contains unexpected char: {}", ch); + } + } + + // Kill: same mutation for the second snap loop after find_word_boundary (lines 102-103) + #[test] + fn truncate_token_mode_word_boundary_snaps_across_multibyte_boundary() { + // "á " = U+00E1 U+0020 — "á" is 2 bytes, space is 1 byte = 3 bytes per pair. + // max_tokens=1, chars_per_token=3, word_boundary=true. + // cut = min(1*3, len) = 3 bytes. "á "[0..3] = bytes [0xC3, 0xA1, 0x20]. + // Byte 3 is the space (a boundary char), which IS a char boundary, so no snap needed + // for the word boundary itself. But the second snap loop (after find_word_boundary) + // still has to handle the case. Let's use a 5-byte-per-pair string: + // "á" "á" = 4 bytes; cut=3 falls inside the second "á". + let mut cfg = token_cfg(Some(1)); + cfg.chars_per_token = 3; + cfg.word_boundary = true; + cfg.ellipsis = String::new(); + // "áaá" = bytes [0xC3,0xA1, 0x61, 0xC3,0xA1] = 5 bytes. + // cut = min(3, 5) = 3 → byte 3 = 0xC3 (start of second "á") — IS a boundary. + // Use "áá" (4 bytes): cut=3, byte 3 = 0xA1, NOT a boundary → loop fires. + let s = "áá"; // 4 bytes, 2 chars + let result = truncate(s, &cfg); + assert!( + std::str::from_utf8(result.as_bytes()).is_ok(), + "result is invalid UTF-8 after word-boundary snap" + ); + } + + // ── find_word_boundary: tighter search_back proportionality test ───────── + // Kill: replace * with + or / in `search_back = (max_chars as f64 * 0.2) as usize` + // + // Strategy: place the only boundary char at exactly position (cut - search_back), + // such that with * 0.2 the window just covers it but with + 0.2 (≈ max_chars) it + // would use a window of size max_chars and still find it. We need a case where + // * 0.2 MISSES the boundary so the test can distinguish by asserting the boundary + // is NOT found. Use max_chars=5: search_back = (5*0.2) = 1. Place space at index cut-2, + // so a window of 1 misses it but a window of 5 (from +) or 25 (from /) finds it. + // + // BUT we want the correct behaviour to FIND the boundary (not miss it), + // so we need the opposite: place boundary INSIDE the 20% window. Use max_chars=20: + // search_back = 4. Place boundary at cut-3 (within window). With + (≈20), also found. + // With / (≈100), also found. We can't distinguish + vs / vs * by finding. + // + // Real kill: use max_chars=5, boundary at cut-1 (right at edge). + // * 0.2 → search_back=1 → min=cut-1 → boundary at cut-1 IS in [cut-1, cut) → found. + // + 0.2 → search_back=5 → min=cut-5 → also found. + // / 0.2 → search_back=25 → min=cut-25(=0) → also found. + // All find it ⇒ can't kill with "found" assertion. + // + // Use max_chars=5, boundary at cut-2 (outside 20% window): + // * 0.2 → search_back=1 → min=cut-1 → boundary at cut-2 NOT in window → returns cut (unchanged). + // + 0.2 → search_back=5 → min=cut-5 → boundary at cut-2 IS in window → found. + // / 0.2 → search_back=25 → large window → found. + // So with the correct * operator: boundary NOT found → pos == cut. + // With + or /: boundary found → pos < cut. + // Test asserts pos == cut (boundary NOT found) ⇒ kills both + and / mutants. + #[test] + fn find_word_boundary_does_not_search_beyond_20_percent_window() { + // String: 10 non-boundary chars, then a space, then 4 more non-boundary chars. + // cut = 15 (total length), max_chars = 5. + // search_back = floor(5 * 0.2) = 1. Window = [14, 15) = only index 14 = 'd'. + // Space is at index 10, outside window → boundary NOT found → returns cut=15. + let s = "abcdefghij klmno"; // space at byte index 10, total 16 chars/bytes + let cut = 15; + let max_chars = 5; + let pos = find_word_boundary(s, cut, max_chars); + // With * 0.2: search_back=1, space at index 10 is outside [14,15) → not found → pos==cut + // With + 0.2: search_back=5, space at index 10 is inside [10,15) → found → pos < cut + // With / 0.2: search_back=25, large window → found → pos < cut + assert_eq!( + pos, cut, + "boundary at index 10 must NOT be found with search_back=1 (max_chars=5, * 0.2)" + ); + } + + #[test] + fn find_word_boundary_finds_boundary_within_20_percent_window() { + // Confirm the opposite: boundary exactly at cut-1 IS found with * 0.2 + // String: 9 non-boundary chars, then a space, so space is at index 9. + // cut=10, max_chars=5 → search_back=1 → min=9 → space at index 9 ∈ [9,10) → found. + // After space at index 9 (inclusive), byte_pos = 10 = cut itself. + // find_word_boundary returns byte_pos = sum of chars[..=i].len_utf8() where i=9. + // chars[..=9] = all 10 chars → byte 10. So pos = 10 = cut. + // Hmm, that means it returns cut even when found. Let me use space NOT as the last char. + // "abcdefghi xyz" — space at index 9, cut=11, max_chars=10 → search_back=2 → min=9. + // space at index 9 ∈ [9,11) → found → byte_pos after index 9 = 10. So pos=10 < 11=cut. ✓ + let s = "abcdefghi xyz"; + let cut = 11; + let max_chars = 10; + let pos = find_word_boundary(s, cut, max_chars); + // search_back = floor(10 * 0.2) = 2 → min = 9. Space at index 9 IS in [9,11). Found. + assert!( + pos < cut, + "boundary at index 9 must be found with search_back=2 (max_chars=10, * 0.2): got pos={}", + pos + ); + } + + // ── evaluate_text_limits: strict pair (line 27/32) ─────────────────────── + // Kill: replace > with >= — length == max+1 must fire; test also verifies == max doesn't. + #[test] + fn evaluate_text_limits_one_above_max_chars_fires_above_max() { + let cfg = char_cfg(Some(100)); + let (_, above) = evaluate_text_limits(101, 0, &cfg); + assert!(above, "length 101 > max 100 must be above_max"); + // And == max must NOT fire (already tested; belt-and-suspenders here) + let (_, above_eq) = evaluate_text_limits(100, 0, &cfg); + assert!(!above_eq, "length == max must not be above_max"); + } + + #[test] + fn evaluate_text_limits_one_above_max_tokens_fires_above_max() { + let cfg = token_cfg(Some(10)); + let (_, above) = evaluate_text_limits(0, 11, &cfg); + assert!(above, "token_count 11 > max 10 must be above_max"); + let (_, above_eq) = evaluate_text_limits(0, 10, &cfg); + assert!(!above_eq, "token_count == max must not be above_max"); + } + + // ── find_word_boundary: line 49 || vs && ──────────────────────────────── + // Kill: replace || with && in `if value.is_empty() || cut == 0`. + // With &&: both must be true → empty string with cut==0 returns early, but + // empty string with cut>0 does NOT (and falls through into the loop). + // Test: empty string, cut=5 → with || (correct): returns cut=5 immediately. + // with && (mutant): !empty && cut>0 = false → proceeds. + // After proceeds: cut = cut.min(value.len()) = 5.min(0) = 0 → empty loop → returns 0 ≠ 5. + #[test] + fn find_word_boundary_empty_string_nonzero_cut_returns_cut_unchanged() { + let pos = find_word_boundary("", 5, 10); + assert_eq!( + pos, 5, + "empty string with cut=5 must return 5; && mutant would return 0" + ); + } + + // ── truncate token-mode line 90: > vs == and > vs >= ──────────────────── + // Kill: replace > with == or >=. + // Need value.len() > max_text_length to distinguish > from ==. + // Use max_tokens=3, cpt=4, max_text_length=8: budget=12, cap=8. + // value.len()=24 > 8 → cap applied → effective=value[..8], cut=min(12,8)=8. + // With == mutant (24==8 false → no cap): cut=min(12,24)=12 → result len=12. + // With >= mutant (24>=8 true → cap): same as > → len=8. + // Correct (>): len=8. + // >= is equivalent to > here but != kills the == mutant. + #[test] + fn truncate_token_mode_caps_value_at_max_text_length() { + let mut cfg = token_cfg(Some(3)); + cfg.chars_per_token = 4; + cfg.max_text_length = 8; + cfg.ellipsis = String::new(); + // 24-char string; estimated=24/4=6 > 3 → truncate. + // With > (correct): cap at 8, cut=min(12,8)=8 → result="aaaaaaaa" (8 bytes) + // With == (mutant): 24==8 false → no cap, cut=min(12,24)=12 → result len=12 + let s = "a".repeat(24); + let result = truncate(&s, &cfg); + assert_eq!( + result.len(), + 8, + "max_text_length=8 must cap result to 8 bytes, got {}", + result.len() + ); + } + + // ── truncate token-mode line 95: * vs + and * vs / ────────────────────── + // `cut = (max_tokens * safe_cpt).min(effective.len())` + // max_tokens=2, safe_cpt=4: * → 8; + → 6; / → 0. + // Use a 20-char string (no cap needed since max_text_length defaults to 1M). + #[test] + fn truncate_token_mode_cut_is_product_of_tokens_and_cpt() { + let mut cfg = token_cfg(Some(2)); + cfg.chars_per_token = 4; + cfg.ellipsis = String::new(); + let s = "a".repeat(20); // estimated=20/4=5 > 2 → truncate + let result = truncate(&s, &cfg); + // correct: cut=2*4=8 → "aaaaaaaa" (8) + // + mutant: cut=2+4=6 → "aaaaaa" (6) + // / mutant: cut=2/4=0 → "" (0) + assert_eq!( + result.len(), + 8, + "cut must be max_tokens*cpt=8, got len={}", + result.len() + ); + } + + // ── truncate token-mode lines 97-98: snap loop -= vs += ───────────────── + // Cut at a non-char-boundary byte; loop must decrement to prior boundary. + // "á"×5 = 10 bytes. max_tokens=1, cpt=3: cut=min(3,10)=3. + // Byte 3=0xA1 (not a boundary). -= loop: cut=2 (boundary). result="á" (2 bytes). + // With += mutant: cut=4 (boundary). result="áá" (4 bytes). Different! + #[test] + fn truncate_token_mode_snap_loop_decrements_to_exact_char_boundary() { + let mut cfg = token_cfg(Some(1)); + cfg.chars_per_token = 3; + cfg.ellipsis = String::new(); + let s: String = "á".repeat(5); // 10 bytes + let result = truncate(&s, &cfg); + // correct -=: cut snaps 3→2 → result="á" (2 bytes, 1 char) + // += mutant: cut increments 3→4 → result="áá" (4 bytes, 2 chars) + assert_eq!( + result, "á", + "snap must decrement to byte 2 (1 'á'), got: {:?}", + result + ); + } + + // ── truncate token-mode line 100: word_boundary && cut > 0 → || or < ──── + // + // For `|| mutant` (word_boundary=false but branch fires): + // Need a case where the boundary IS within the search window so the || mutant + // actually changes the output. Use max_tokens=5, cpt=4 on a 24-char string. + // cut = min(5*4, 24) = 20. search_back = floor(20*0.2) = 4. Window = [16,20). + // Place a space at byte 16 in the 24-char string → found at pos 17. + // With && and word_boundary=false: skips → result = first 20 chars + ellipsis. + // With || mutant: fires → result = first 17 chars (up to space) + ellipsis. Different! + #[test] + fn truncate_token_mode_no_word_boundary_does_not_invoke_boundary_search() { + let mut cfg = token_cfg(Some(5)); + cfg.chars_per_token = 4; + cfg.word_boundary = false; + cfg.ellipsis = String::new(); + // Build: 16 'a's + ' ' + 7 'b's = 24 bytes. + // estimated = 24/4 = 6 > 5 → truncate. + // cut = min(20, 24) = 20. + // search_back = floor(20*0.2) = 4. Window = [16,20). + // Space at byte 16 ∈ [16,20) → find_word_boundary WOULD find it → pos=17. + // With && (word_boundary=false): skips → cut=20 → result = "a"*16 + " " + "bbb" (20 chars). + // With || mutant: fires → cut=17 → result = "a"*16 + " " (17 chars). Different! + let s: String = "a".repeat(16) + " " + &"b".repeat(7); // 24 bytes + let result = truncate(&s, &cfg); + assert_eq!( + result.len(), + 20, + "word_boundary=false must keep cut at 20 (no boundary search), got len={}: {:?}", + result.len(), + result + ); + } + + // For `< 0 mutant` (word_boundary=true but branch never fires): + // Need word_boundary=true and a boundary within the search window so the + // correct code DOES adjust cut but the `< 0` mutant (never fires) does NOT. + #[test] + fn truncate_token_mode_word_boundary_true_adjusts_cut_when_space_in_window() { + let mut cfg = token_cfg(Some(5)); + cfg.chars_per_token = 4; + cfg.word_boundary = true; + cfg.ellipsis = String::new(); + // Same string as above: 16 'a's + ' ' + 7 'b's = 24 bytes. cut=20, search_back=4. + // Space at byte 16 ∈ [16,20) → pos=17. + // With && and word_boundary=true: cut→17 → result = "a"*16 + " " (17 bytes). + // With `cut < 0` mutant (never fires): cut stays at 20 → result = "a"*20 (20 bytes). + let s: String = "a".repeat(16) + " " + &"b".repeat(7); + let result = truncate(&s, &cfg); + assert_eq!( + result.len(), + 17, + "word_boundary=true must adjust cut to 17 (after space at byte 16), got len={}: {:?}", + result.len(), + result + ); + } + + // ── truncate token-mode line 102-103: second snap loop ────────────────── + // The second snap loop (after find_word_boundary) fires only when find_word_boundary + // returns a non-char-boundary position. Since all BOUNDARY_CHARS are ASCII (single byte), + // the returned byte_pos is always a valid UTF-8 char boundary. So lines 102-103 are + // equivalent mutants for all valid UTF-8 inputs. We cover the code path for completeness: + #[test] + fn truncate_token_mode_second_snap_loop_path_is_exercised_with_word_boundary() { + let mut cfg = token_cfg(Some(5)); + cfg.chars_per_token = 4; + cfg.word_boundary = true; + cfg.ellipsis = String::new(); + let s: String = "a".repeat(16) + " " + &"b".repeat(7); + let result = truncate(&s, &cfg); + assert!( + std::str::from_utf8(result.as_bytes()).is_ok(), + "result must be valid UTF-8: {:?}", + result + ); + } + + // ── truncate char-mode line 136: word_boundary && cut_byte > 0 → || ───── + // Kill: && → || means word_boundary=false still runs boundary search. + // + // Need: space within the 20% search window so || mutant actually changes result. + // max_chars=20, ellipsis="…" (1 char): cut_char=19, cut_byte=19. + // search_back = floor(20*0.2) = 4. Window = [15, 19). + // Build: 16 'a's + ' ' + 2 'b's = 19 chars (exactly cut_char). Space at byte 16 ∈ [15,19). + // find_word_boundary(s, 19, 20): found at index 16 → byte_pos=17. adj=17 < 19. + // With && (word_boundary=false, correct): skips → cut_byte=19 → result="a"*16 + " " + "bb" + "…" = 20 chars. + // With || mutant: fires → cut_byte=17 → result="a"*16 + " " + "…" = 18 chars. Different! + #[test] + fn truncate_char_mode_no_word_boundary_does_not_invoke_boundary_search() { + let mut cfg = char_cfg(Some(20)); + cfg.word_boundary = false; + cfg.ellipsis = "…".to_string(); + // 16 'a's + ' ' + 'b'*3 = 20 chars. String is longer than max_chars, so truncation fires. + // Actually: char_count = 20 = max_chars → no truncation (early return)! + // Need char_count > max_chars. Use 22-char string: 16 'a's + ' ' + 5 'b's = 22 chars. + // cut_char = 19 (20-1 for "…"). Space at byte 16 ∈ [15,19) → within window. + let s: String = "a".repeat(16) + " " + &"b".repeat(5); // 22 chars + let result = truncate(&s, &cfg); + // With && (word_boundary=false): skips → cut_byte=19 → result[..19]="aaaaaaaaaaaaaaaa bb" + "…" = 20 chars. + // With || mutant: fires → cut_byte=17 → result="a"*16 + " " + "…" = 18 chars. Different! + assert_eq!( + result.chars().count(), + 20, + "word_boundary=false must hard-cut at char 19 (20 chars total with ellipsis), got: {:?}", + result + ); + assert!( + result.starts_with(&"a".repeat(16)), + "hard cut must not stop at word boundary, got: {:?}", + result + ); + // Crucially: must NOT stop at the space (which is at char 16) + assert!( + result.chars().count() > 17, + "hard cut must not stop at word boundary char 16, got: {:?}", + result + ); + } + + // ── truncate char-mode line 139: adj <= cut_byte → > ──────────────────── + // Kill: <= → > means: only update cut_byte when adj > cut_byte (EXTENDS it). + // When adj < cut_byte (word boundary before cut): with <= updates (correct); with > doesn't. + // + // Use the same 22-char string with word_boundary=true so the boundary IS applied. + // cut_char=19, cut_byte=19. Space at byte 16. adj=17 < 19 → with <= update. + // With > mutant: 17 > 19 false → no update → result stays at 20 chars. + // With <= (correct): cut_byte=17 → result stops at space → shorter. + #[test] + fn truncate_char_mode_word_boundary_adj_less_than_cut_updates_cut_byte() { + let mut cfg = char_cfg(Some(20)); + cfg.word_boundary = true; + cfg.ellipsis = "…".to_string(); + let s: String = "a".repeat(16) + " " + &"b".repeat(5); // 22 chars + let result = truncate(&s, &cfg); + // With <= (correct): adj=17, cut_byte=17. result = "a"*16 + " " + "…" = 18 chars. + // With > mutant: no update, cut_byte=19. result = "a"*16 + " " + "bb" + "…" = 20 chars. + // Assert the boundary adjustment was applied: result ≤ 18 chars (not 20). + assert!( + result.chars().count() <= 18, + "adj <= cut_byte must apply boundary adjustment, got: {:?}", + result + ); + assert!( + result.starts_with(&"a".repeat(16)), + "result must start with the 'a' prefix" + ); + } } diff --git a/plugins/rust/python-package/output_length_guard/src/lib.rs b/plugins/rust/python-package/output_length_guard/src/lib.rs index 79f9e53..81e42df 100644 --- a/plugins/rust/python-package/output_length_guard/src/lib.rs +++ b/plugins/rust/python-package/output_length_guard/src/lib.rs @@ -17,6 +17,7 @@ pub mod structured; pub use plugin::OutputLengthGuardPluginCore; +#[mutants::skip] // logging initialisation: side-effect only, not observable in unit tests fn init_logging() { static INIT: Once = Once::new(); INIT.call_once(|| { diff --git a/plugins/rust/python-package/output_length_guard/src/plugin.rs b/plugins/rust/python-package/output_length_guard/src/plugin.rs index df8fa32..6e7674a 100644 --- a/plugins/rust/python-package/output_length_guard/src/plugin.rs +++ b/plugins/rust/python-package/output_length_guard/src/plugin.rs @@ -1502,4 +1502,393 @@ class Payload: ); }); } + + // ── new_text_str (plugin.rs:802): replace with String::new() or "xyzzy" ── + // The function is called to produce the `new_text` string used in build_text_meta. + // build_text_meta sets new_length = new_text.len(). If the mutant returns "", + // new_length == 0; if it returns "xyzzy", new_length == 5 (always). + // We kill both by asserting new_length > 0 AND new_length != 5. + #[test] + fn truncated_plain_string_new_length_is_positive_and_not_xyzzy() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + let core = make_core(Some(8), "truncate").unwrap(); + // "hello world" = 11 chars > 8 → truncated. Truncated result has 7 chars + // (max_chars - ell_chars = 8 - 1 = 7) + "…" = 8 chars ≈ >5 bytes. + let text = "hello world".into_pyobject(py).unwrap().into_any(); + let payload = make_payload(py, "t", text).unwrap(); + let ctx = PyDict::new(py); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), None) + .unwrap(); + let result = result.bind(py); + let meta = result.getattr("metadata").unwrap(); + let new_length: usize = meta.get_item("new_length").unwrap().extract().unwrap(); + assert!(new_length > 0, "new_length must be > 0, got {}", new_length); + // Kill "xyzzy" mutant: the actual truncated "hello w…" is 10 bytes, not 5. + assert_ne!(new_length, 5, "new_length must not be 5 (xyzzy length)"); + }); + } + + // ── handle_string_list += counters (lines 218-219): += vs *= ───────────── + // With *=, total_chars_truncated and items_modified stay at 0 forever. + // push_metrics_kwargs is a no-op without trace_id, so we need to use a trace_id + // and verify the emitted metadata reflects non-zero chars_seen / truncated_count. + // We use a helper to inject an extensions object carrying a trace_id. + fn make_extensions<'py>(py: Python<'py>, trace_id: &str) -> PyResult> { + let module = PyModule::from_code( + py, + pyo3::ffi::c_str!( + "class Req:\n def __init__(self, t):\n self.trace_id = t\n\ + class Ext:\n def __init__(self, t):\n self.request = Req(t)\n" + ), + pyo3::ffi::c_str!("ext2.py"), + pyo3::ffi::c_str!("ext2"), + )?; + module.getattr("Ext")?.call1((trace_id,)) + } + + #[test] + fn string_list_with_trace_id_metrics_have_nonzero_chars_seen() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + let core = make_core(Some(3), "truncate").unwrap(); + // "hello" = 5 chars > 3 → truncated. total_chars_seen += 5 → must be 5, not 0. + let list = PyList::new(py, ["hello"]).unwrap(); + let payload = make_payload(py, "t", list.as_any().clone()).unwrap(); + let ctx = PyDict::new(py); + let ext = make_extensions(py, "trace-abc").unwrap(); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), Some(&ext)) + .unwrap(); + let result = result.bind(py); + // metadata should be the metrics dict {output_length_guard: {...}} + let meta = result.getattr("metadata").unwrap(); + // The metrics dict is keyed by PLUGIN_KEY = "output_length_guard" + let inner = meta.get_item("output_length_guard").unwrap(); + assert!( + !inner.is_none(), + "output_length_guard metrics must be present" + ); + let chars_seen: usize = inner.get_item("chars_seen").unwrap().extract().unwrap(); + assert!(chars_seen > 0, "chars_seen must be > 0, got {}", chars_seen); + let truncated_count: usize = inner + .get_item("truncated_count") + .unwrap() + .extract() + .unwrap(); + assert!( + truncated_count > 0, + "truncated_count must be > 0, got {}", + truncated_count + ); + }); + } + + // ── process_mcp_items_result += counters (lines 413-414): text items ───── + #[test] + fn mcp_content_dict_with_trace_id_metrics_have_nonzero_chars_seen() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + let core = make_core(Some(3), "truncate").unwrap(); + let item = PyDict::new(py); + item.set_item("type", "text").unwrap(); + item.set_item("text", "hello world").unwrap(); // 11 chars > 3 + let content = PyList::new(py, [item]).unwrap(); + let result_dict = PyDict::new(py); + result_dict.set_item("content", content).unwrap(); + let payload = make_payload(py, "t", result_dict.as_any().clone()).unwrap(); + let ctx = PyDict::new(py); + let ext = make_extensions(py, "trace-def").unwrap(); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), Some(&ext)) + .unwrap(); + let result = result.bind(py); + let meta = result.getattr("metadata").unwrap(); + let inner = meta.get_item("output_length_guard").unwrap(); + assert!( + !inner.is_none(), + "output_length_guard metrics must be present" + ); + let chars_seen: usize = inner.get_item("chars_seen").unwrap().extract().unwrap(); + assert!(chars_seen > 0, "chars_seen must be > 0, got {}", chars_seen); + }); + } + + // ── process_mcp_items_result += counters (lines 442-443): resource items ── + #[test] + fn mcp_resource_item_with_trace_id_metrics_have_nonzero_chars_seen() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + let core = make_core(Some(3), "truncate").unwrap(); + let resource = PyDict::new(py); + resource.set_item("text", "toolongtext").unwrap(); // 11 chars > 3 + let item = PyDict::new(py); + item.set_item("type", "resource").unwrap(); + item.set_item("resource", resource).unwrap(); + let content = PyList::new(py, [item]).unwrap(); + let result_dict = PyDict::new(py); + result_dict.set_item("content", content).unwrap(); + let payload = make_payload(py, "t", result_dict.as_any().clone()).unwrap(); + let ctx = PyDict::new(py); + let ext = make_extensions(py, "trace-ghi").unwrap(); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), Some(&ext)) + .unwrap(); + let result = result.bind(py); + let meta = result.getattr("metadata").unwrap(); + let inner = meta.get_item("output_length_guard").unwrap(); + assert!( + !inner.is_none(), + "output_length_guard metrics must be present for resource item" + ); + let chars_seen: usize = inner.get_item("chars_seen").unwrap().extract().unwrap(); + assert!( + chars_seen > 0, + "chars_seen must be > 0 for resource item, got {}", + chars_seen + ); + }); + } + + // ── process_mcp_items_result > vs < on structure size (line 369) ───────── + // Kill: replace > with <: a list SMALLER than max_structure_size must NOT block. + #[test] + fn mcp_content_dict_under_max_structure_size_is_not_blocked() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + let d = PyDict::new(py); + d.set_item("max_chars", py.None()).unwrap(); + d.set_item("max_structure_size", 10usize).unwrap(); + d.set_item("strategy", "block").unwrap(); + d.set_item("limit_mode", "character").unwrap(); + let core = OutputLengthGuardPluginCore::new(d.as_any()).unwrap(); + // Only 2 items — well under the limit of 10 + let item_a = PyDict::new(py); + item_a.set_item("type", "text").unwrap(); + item_a.set_item("text", "a").unwrap(); + let item_b = PyDict::new(py); + item_b.set_item("type", "text").unwrap(); + item_b.set_item("text", "b").unwrap(); + let content = PyList::new(py, [item_a, item_b]).unwrap(); + let result_dict = PyDict::new(py); + result_dict.set_item("content", content).unwrap(); + let payload = make_payload(py, "t", result_dict.as_any().clone()).unwrap(); + let ctx = PyDict::new(py); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), None) + .unwrap(); + let cp: bool = result + .bind(py) + .getattr("continue_processing") + .unwrap() + .extract() + .unwrap(); + assert!( + cp, + "list with 2 items < max_structure_size=10 must not block" + ); + }); + } + + // ── process_mcp_items_result line 369: == with != on Strategy::Block ───── + // Kill: replace == with != → truncate strategy would trigger the block. + // Test: strategy=truncate (not Block), oversized list → must NOT block. + #[test] + fn mcp_content_dict_oversized_list_in_truncate_mode_is_not_blocked() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + let d = PyDict::new(py); + d.set_item("max_chars", py.None()).unwrap(); + d.set_item("max_structure_size", 2usize).unwrap(); + // Truncate strategy — oversized list must NOT be blocked + d.set_item("strategy", "truncate").unwrap(); + d.set_item("limit_mode", "character").unwrap(); + let core = OutputLengthGuardPluginCore::new(d.as_any()).unwrap(); + // 5 items > max_structure_size=2, but strategy=truncate → must NOT block + let item_a = PyDict::new(py); + item_a.set_item("type", "text").unwrap(); + item_a.set_item("text", "a").unwrap(); + let item_b = PyDict::new(py); + item_b.set_item("type", "text").unwrap(); + item_b.set_item("text", "b").unwrap(); + let item_c = PyDict::new(py); + item_c.set_item("type", "text").unwrap(); + item_c.set_item("text", "c").unwrap(); + let content = PyList::new(py, [item_a, item_b, item_c]).unwrap(); + let result_dict = PyDict::new(py); + result_dict.set_item("content", content).unwrap(); + let payload = make_payload(py, "t", result_dict.as_any().clone()).unwrap(); + let ctx = PyDict::new(py); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), None) + .unwrap(); + let cp: bool = result + .bind(py) + .getattr("continue_processing") + .unwrap() + .extract() + .unwrap(); + assert!( + cp, + "truncate strategy must not block oversized list; != mutant would block" + ); + }); + } + + // ── process_mcp_items_result lines 414, 443: items_modified_count += vs *= ─ + // truncated_count = items_modified_count. With *= mutant, items_modified_count stays 0. + #[test] + fn mcp_content_dict_text_item_trace_id_truncated_count_is_nonzero() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + let core = make_core(Some(3), "truncate").unwrap(); + let item = PyDict::new(py); + item.set_item("type", "text").unwrap(); + item.set_item("text", "hello world").unwrap(); + let content = PyList::new(py, [item]).unwrap(); + let result_dict = PyDict::new(py); + result_dict.set_item("content", content).unwrap(); + let payload = make_payload(py, "t", result_dict.as_any().clone()).unwrap(); + let ctx = PyDict::new(py); + let ext = make_extensions(py, "trace-xyz").unwrap(); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), Some(&ext)) + .unwrap(); + let result = result.bind(py); + let meta = result.getattr("metadata").unwrap(); + let inner = meta.get_item("output_length_guard").unwrap(); + assert!(!inner.is_none()); + let truncated_count: usize = inner + .get_item("truncated_count") + .unwrap() + .extract() + .unwrap(); + assert!( + truncated_count > 0, + "truncated_count must be > 0 (items_modified_count += 1), got {}", + truncated_count + ); + }); + } + + #[test] + fn mcp_resource_item_trace_id_truncated_count_is_nonzero() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + let core = make_core(Some(3), "truncate").unwrap(); + let resource = PyDict::new(py); + resource.set_item("text", "toolongtext").unwrap(); + let item = PyDict::new(py); + item.set_item("type", "resource").unwrap(); + item.set_item("resource", resource).unwrap(); + let content = PyList::new(py, [item]).unwrap(); + let result_dict = PyDict::new(py); + result_dict.set_item("content", content).unwrap(); + let payload = make_payload(py, "t", result_dict.as_any().clone()).unwrap(); + let ctx = PyDict::new(py); + let ext = make_extensions(py, "trace-res").unwrap(); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), Some(&ext)) + .unwrap(); + let result = result.bind(py); + let meta = result.getattr("metadata").unwrap(); + let inner = meta.get_item("output_length_guard").unwrap(); + assert!(!inner.is_none()); + let truncated_count: usize = inner + .get_item("truncated_count") + .unwrap() + .extract() + .unwrap(); + assert!( + truncated_count > 0, + "truncated_count must be > 0 for resource item, got {}", + truncated_count + ); + }); + } + + // ── find_struct_key line 762: delete ! in !val.is_none() ───────────────── + // With ! deleted: val.is_none() → true means "present" → None-valued key treated as found. + // The existing test checks truncation still happens with None structuredContent. + // But with the mutant, None-valued structuredContent IS found → process_structured_data + // gets Python None → falls through as Ok{modified=false}. Then content is still processed. + // The real observable difference: with non-None structuredContent, verify it IS found. + // Also verify None structuredContent is NOT treated as present (struct_key is None). + #[test] + fn mcp_content_dict_nonnone_structured_content_sets_structured_content_processed() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + let core = make_core(Some(1000), "truncate").unwrap(); + let sc = PyDict::new(py); + sc.set_item("data", "value").unwrap(); + let item = PyDict::new(py); + item.set_item("type", "text").unwrap(); + item.set_item("text", "short").unwrap(); + let content = PyList::new(py, [item]).unwrap(); + let result_dict = PyDict::new(py); + result_dict.set_item("content", content).unwrap(); + result_dict.set_item("structuredContent", &sc).unwrap(); + let payload = make_payload(py, "t", result_dict.as_any().clone()).unwrap(); + let ctx = PyDict::new(py); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), None) + .unwrap(); + let result = result.bind(py); + let meta = result.getattr("metadata").unwrap(); + let meta_dict = meta.cast::().unwrap(); + let sc_processed: bool = meta_dict + .get_item("structured_content_processed") + .unwrap() + .map(|v| v.extract::().unwrap_or(false)) + .unwrap_or(false); + assert!(sc_processed, "non-None structuredContent must be detected"); + }); + } + + #[test] + fn mcp_content_dict_none_structured_content_does_not_set_structured_content_processed() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + let core = make_core(Some(1000), "truncate").unwrap(); + let item = PyDict::new(py); + item.set_item("type", "text").unwrap(); + item.set_item("text", "short").unwrap(); + let content = PyList::new(py, [item]).unwrap(); + let result_dict = PyDict::new(py); + result_dict.set_item("content", content).unwrap(); + // structuredContent = None → must NOT be treated as present + result_dict + .set_item("structuredContent", py.None()) + .unwrap(); + let payload = make_payload(py, "t", result_dict.as_any().clone()).unwrap(); + let ctx = PyDict::new(py); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), None) + .unwrap(); + let result = result.bind(py); + let meta = result.getattr("metadata").unwrap(); + let meta_dict = meta.cast::().unwrap(); + // sc_processed = (struct_key.is_some()) = false when structuredContent is None + let sc_processed: bool = meta_dict + .get_item("structured_content_processed") + .unwrap() + .map(|v| v.extract::().unwrap_or(true)) + .unwrap_or(true); + assert!( + !sc_processed, + "None structuredContent must NOT be treated as present; ! deletion mutant would set sc_processed=true" + ); + }); + } } diff --git a/plugins/rust/python-package/output_length_guard/src/structured.rs b/plugins/rust/python-package/output_length_guard/src/structured.rs index a1f3f7a..e4a4e73 100644 --- a/plugins/rust/python-package/output_length_guard/src/structured.rs +++ b/plugins/rust/python-package/output_length_guard/src/structured.rs @@ -645,6 +645,74 @@ mod tests { }); } + // Kill: replace / with % in token_count = length / cpt. + // length=9, cpt=4, max_tokens=1: + // / → 9/4=2 > 1 → violation ✓ + // % → 9%4=1 > 1 → false → no violation ✗ (mutant survives without this test) + #[test] + fn process_string_token_mode_modulo_mutant_is_killed() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let cfg = OutputLengthGuardConfig { + max_tokens: Some(1), + limit_mode: LimitMode::Token, + strategy: Strategy::Block, + chars_per_token: 4, + max_chars: None, + ellipsis: "…".to_string(), + ..Default::default() + }; + // length=9: 9/4=2 > 1 → violation; 9%4=1 is not > 1 → would pass through + let s = "abcdefghi".into_pyobject(py).unwrap().into_any(); // 9 chars + match process_structured_data(py, &s, &cfg, "", 0).unwrap() { + ProcessResult::Violation { code, .. } => { + assert_eq!( + code, "OUTPUT_TOKEN_VIOLATION", + "9/4=2 > max_tokens=1 must violate" + ) + } + ProcessResult::Ok { .. } => { + panic!( + "expected token violation: 9/4=2 > max_tokens=1; % mutant gives 9%4=1 which would pass" + ) + } + } + }); + } + + // Kill: replace / with * in token_count = length / cpt. + // length=4, cpt=4, max_tokens=1: + // / → 4/4=1 > 1 → false → no violation ✓ + // * → 4*4=16 > 1 → violation ✗ (mutant would block) + #[test] + fn process_string_token_mode_multiply_mutant_is_killed() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let cfg = OutputLengthGuardConfig { + max_tokens: Some(1), + limit_mode: LimitMode::Token, + strategy: Strategy::Block, + chars_per_token: 4, + max_chars: None, + ellipsis: "…".to_string(), + ..Default::default() + }; + // length=4: 4/4=1, NOT > 1 → no violation; 4*4=16 > 1 → would block + let s = "abcd".into_pyobject(py).unwrap().into_any(); // exactly 1 token + match process_structured_data(py, &s, &cfg, "", 0).unwrap() { + ProcessResult::Ok { modified, .. } => { + assert!( + !modified, + "4 chars = 1 token = max_tokens: must not block or modify" + ); + } + ProcessResult::Violation { .. } => { + panic!("4/4=1 is not > max_tokens=1; * mutant gives 4*4=16 which would block"); + } + } + }); + } + // Kill: delete ! in `if !below_min && !above_max` (line 108) #[test] fn process_string_within_both_limits_passes_through_unmodified() { @@ -794,4 +862,118 @@ mod tests { assert_eq!(result, "leaf"); }); } + + // ── process_list depth+1 vs depth*1 (structured.rs:250) ───────────────── + // Kill: replace + with * in `depth + 1` inside process_list recursive call. + // With depth*1, the depth never increments, so max_recursion_depth is never reached. + // Strategy: set max_recursion_depth=1, build list-inside-list (depth 0 → 1 → must hit 2). + // With correct depth+1: outer call at depth=0, list processes items at depth=1. + // Items at depth=1 are strings → process_string at depth=1. But depth check is + // `if depth > max_recursion_depth` at the TOP of process_structured_data. + // depth=1 > max=1 → false (doesn't fire). We need depth=2 to trigger. + // Use max_recursion_depth=1, and a list containing another list containing a string. + // Outer list: depth=0 → items processed at depth=1. + // Inner list: depth=1 → items processed at depth=2. depth=2 > max=1 → fires! + // With depth*1: items always processed at depth=0 (or depth*1=depth=0 since initial depth=0). + // Actually depth*1 = depth. So if initial depth=0: 0*1=0, never increments. + // Items in inner list would be processed at depth=0, so no depth violation. + #[test] + fn process_list_depth_increments_catch_deeply_nested_list() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let mut cfg = block_char_cfg(10000); // no char limit + cfg.max_recursion_depth = 1; + // Build: [["short_string"]] — depth 0 → list → depth 1 → list → depth 2 > 1 → violation + let inner_list = PyList::new(py, ["short_string"]).unwrap(); + let outer_list = PyList::new(py, [inner_list]).unwrap(); + // With correct depth+1: outer processes items at depth=1; inner list at depth=1 + // processes strings at depth=2; depth=2 > max=1 → STRUCTURE_DEPTH_VIOLATION. + // With depth*1 (depth never increments): always at depth=0, never fires. + match process_structured_data(py, outer_list.as_any(), &cfg, "", 0).unwrap() { + ProcessResult::Violation { code, .. } => { + assert_eq!( + code, "STRUCTURE_DEPTH_VIOLATION", + "expected depth violation from nested list, got: {}", + code + ); + } + ProcessResult::Ok { .. } => { + panic!("nested list beyond max_recursion_depth must produce a depth violation") + } + } + }); + } + + // ── process_dict depth+1 vs depth*1 (structured.rs:323) ───────────────── + // Same as above but using nested dicts. + #[test] + fn process_dict_depth_increments_catch_deeply_nested_dict() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + let mut cfg = block_char_cfg(10000); + cfg.max_recursion_depth = 1; + // Build: {"outer": {"inner": "value"}} — depth 0 → dict → depth 1 → dict → depth 2 > 1 → violation + let inner_dict = PyDict::new(py); + inner_dict.set_item("inner", "value").unwrap(); + let outer_dict = PyDict::new(py); + outer_dict.set_item("outer", inner_dict).unwrap(); + match process_structured_data(py, outer_dict.as_any(), &cfg, "", 0).unwrap() { + ProcessResult::Violation { code, .. } => { + assert_eq!( + code, "STRUCTURE_DEPTH_VIOLATION", + "expected depth violation from nested dict, got: {}", + code + ); + } + ProcessResult::Ok { .. } => { + panic!("nested dict beyond max_recursion_depth must produce a depth violation") + } + } + }); + } + + // ── generate_text_representation depth+1 vs depth*1 (structured.rs:356) ── + // Kill: replace + with * in the recursive `generate_text_representation(&val, depth + 1)`. + // With depth*1: depth = 0 always (since 0*1=0). A chain of 11 single-key dicts would + // never hit the depth<10 limit → infinite recursion or incorrect result. + // We verify correct behaviour by using exactly 11 levels of nesting: + // d1 = {"k": d2}, d2 = {"k": d3}, ..., d10 = {"k": d11}, d11 = {"k": "leaf"} + // With correct depth+1: unwrapping stops at depth=10 → json_dumps → contains "k"/"leaf". + // With depth*1: never increments, so all 11 levels are traversed → returns "leaf". + // BUT we verify from depth=0 that the FIRST single-key dict IS unwrapped (depth=0 < 10). + // What matters is what happens at depth=10 inside the chain. The test at depth=10 already + // covers that. For the +1 vs *1 mutation we need the RECURSIVE call to hit the limit. + // Test: build chain of 10 nested dicts (depth 0-9 each unwrap), leaf at d10. + // With +1: d0 calls d1 at depth=1, d1 calls d2 at depth=2, ..., d9 calls d10 at depth=10. + // At depth=10: 10 < 10 is false → json_dumps(d10) → contains "k". + // With *1: d0 calls d1 at depth=0*1=0 (stays 0 each time) → "leaf" returned forever. + // So at depth=0 with a 10-level chain: correct (+1) → JSON; mutant (*1) → "leaf". + #[test] + fn generate_text_representation_chain_of_10_stops_at_depth_limit() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + // Build a chain of 11 nested single-key dicts: each has key "k" pointing to the next. + // The innermost (11th) is {"k": "leaf"}. + // At depth=0, 10 dicts can be unwrapped (depths 0-9 are < 10). + // The 11th dict is reached at depth=10 → depth < 10 is false → json_dumps → not "leaf". + let mut current: pyo3::Py = { + let d = PyDict::new(py); + d.set_item("k", "leaf").unwrap(); + d.into_any().unbind() + }; + for _ in 0..10 { + let d = PyDict::new(py); + d.set_item("k", current.bind(py)).unwrap(); + current = d.into_any().unbind(); + } + // current is the outermost dict (10 wrappers + 1 leaf = 11 levels total) + let result = generate_text_representation(current.bind(py), 0).unwrap(); + // With correct +1: the 11th dict (at depth=10) is json_dumps'd → NOT "leaf". + // With *1 mutation: always at depth=0, unwraps all → returns "leaf". + assert_ne!( + result, "leaf", + "chain of 11 single-key dicts must not return bare 'leaf' (depth limit must fire at depth=10)" + ); + }); + } } From b0d8acf4692a0bea28237773eb0b8546de606f99 Mon Sep 17 00:00:00 2001 From: prakhar-singh1928 Date: Tue, 25 Aug 2026 11:48:03 +0100 Subject: [PATCH 07/16] chore(output_length_guard): add mutants workspace dep for #[mutants::skip] The mutants = "0.0.4" crate is a zero-cost compile-time-only crate that defines the #[mutants::skip] proc-macro attribute. It is required for the nine annotations added in the previous commit (equivalent-mutant helpers and init_logging). Pattern matches sql_sanitizer which carries the same dep. Signed-off-by: prakhar-singh1928 --- plugins/rust/python-package/output_length_guard/Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/rust/python-package/output_length_guard/Cargo.toml b/plugins/rust/python-package/output_length_guard/Cargo.toml index 125c53f..75c11ab 100644 --- a/plugins/rust/python-package/output_length_guard/Cargo.toml +++ b/plugins/rust/python-package/output_length_guard/Cargo.toml @@ -23,6 +23,7 @@ stub-gen = ["dep:pyo3-stub-gen"] [dependencies] cpex_framework_bridge = { workspace = true } log = { workspace = true } +mutants = { workspace = true } pyo3 = { workspace = true } pyo3-log = { workspace = true } pyo3-stub-gen = { workspace = true, optional = true } From 0d66b8e26f1a4eb3796a30fc422def10446ec934 Mon Sep 17 00:00:00 2001 From: prakhar-singh1928 Date: Tue, 25 Aug 2026 12:50:06 +0100 Subject: [PATCH 08/16] add mutants to cargo.lock Signed-off-by: prakhar-singh1928 --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index f61cca1..43bc537 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1102,6 +1102,7 @@ dependencies = [ "cpex_framework_bridge", "criterion", "log", + "mutants", "pyo3", "pyo3-log", "pyo3-stub-gen", From f7374ab67c07f1980aab4f44af0cd83ed45c8275 Mon Sep 17 00:00:00 2001 From: prakhar-singh1928 Date: Wed, 26 Aug 2026 13:01:33 +0100 Subject: [PATCH 09/16] fix(output_length_guard): correct metrics mode/strategy and enforce max_structure_size in truncate mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs fixed in plugin.rs: 1. push_metrics_kwargs and build_blocked_result emitted hardcoded mode: character and strategy: truncate/block regardless of the plugin's actual configuration. Any deployment using limit_mode: token would see {limit_mode: character} in every OTel trace — silently wrong. Fix: add cfg: &OutputLengthGuardConfig to both functions and use cfg.limit_mode.as_str() / cfg.strategy.as_str() at the MetricsArgs construction sites. All 8 call sites updated to pass &self.cfg. Regression test: token_mode_metrics_emit_correct_limit_mode — asserts that a token-mode plugin emits limit_mode=token in traced metadata. 2. process_mcp_items_result guarded max_structure_size with a compound condition (), so Truncate mode would iterate arbitrarily large content arrays with no size cap — a DoS vector for oversized LLM tool responses. Fix: split the condition to match the established pattern in structured.rs::process_list / process_dict — check size unconditionally (log error), then branch on strategy: Block returns a STRUCTURE_SIZE_VIOLATION; Truncate passes the list through unchanged (individual item text is still guarded below). Regression test: mcp_content_dict_oversized_list_truncate_mode_- passes_through_unchanged — sends a 3-item list against max_structure_size=2 / strategy=truncate and asserts no block. All checks pass: cargo clippy -p output_length_guard -- -D warnings ok cargo test -p output_length_guard 139/139 ok Signed-off-by: prakhar.singh1928@ibm.com Signed-off-by: prakhar-singh1928 --- .../output_length_guard/src/plugin.rs | 175 ++++++++++++++---- 1 file changed, 143 insertions(+), 32 deletions(-) diff --git a/plugins/rust/python-package/output_length_guard/src/plugin.rs b/plugins/rust/python-package/output_length_guard/src/plugin.rs index 6e7674a..38cd54a 100644 --- a/plugins/rust/python-package/output_length_guard/src/plugin.rs +++ b/plugins/rust/python-package/output_length_guard/src/plugin.rs @@ -111,14 +111,14 @@ impl OutputLengthGuardPluginCore { _name: &str, ) -> PyResult> { match handle_text(py, text, &self.cfg)? { - TextResult::Violation(v) => build_blocked_result(py, trace_id, v), + TextResult::Violation(v) => build_blocked_result(py, trace_id, v, &self.cfg), TextResult::Modified(new_text) => { let new_result_obj = new_text.into_pyobject(py)?.into_any().unbind(); let new_payload = clone_payload_with_attr(py, payload, "result", &new_result_obj)?; let meta = build_text_meta(py, text, &new_text_str(&new_result_obj, py), false)?; let mut kwargs: Vec<(&str, Py)> = vec![("modified_payload", new_payload), ("metadata", meta)]; - push_metrics_kwargs(py, trace_id, &mut kwargs, text.len(), true, 1)?; + push_metrics_kwargs(py, trace_id, &mut kwargs, text.len(), true, 1, &self.cfg)?; build_result_dyn(py, "ToolPostInvokeResult", kwargs) } TextResult::Unchanged => { @@ -146,14 +146,14 @@ impl OutputLengthGuardPluginCore { }; match handle_text(py, &text, &self.cfg)? { - TextResult::Violation(v) => build_blocked_result(py, trace_id, v), + TextResult::Violation(v) => build_blocked_result(py, trace_id, v, &self.cfg), TextResult::Modified(new_text) => { let new_dict = clone_dict_with_key(py, result_dict, "text", &new_text)?; let new_payload = clone_payload_with_attr(py, payload, "result", &new_dict)?; let meta = build_text_meta(py, &text, &new_text, false)?; let mut kwargs: Vec<(&str, Py)> = vec![("modified_payload", new_payload), ("metadata", meta)]; - push_metrics_kwargs(py, trace_id, &mut kwargs, text.len(), true, 1)?; + push_metrics_kwargs(py, trace_id, &mut kwargs, text.len(), true, 1, &self.cfg)?; build_result_dyn(py, "ToolPostInvokeResult", kwargs) } TextResult::Unchanged => { @@ -175,7 +175,7 @@ impl OutputLengthGuardPluginCore { let (out_items, was_modified, total_chars, items_modified) = match self.process_mcp_items_result(py, list, trace_id)? { Ok(r) => r, - Err(violation) => return build_blocked_result(py, trace_id, violation), + Err(violation) => return build_blocked_result(py, trace_id, violation, &self.cfg), }; if was_modified { @@ -188,7 +188,7 @@ impl OutputLengthGuardPluginCore { ("modified_payload", new_payload), ("metadata", meta.into_any().unbind()), ]; - push_metrics_kwargs(py, trace_id, &mut kwargs, total_chars, true, items_modified)?; + push_metrics_kwargs(py, trace_id, &mut kwargs, total_chars, true, items_modified, &self.cfg)?; return build_result_dyn(py, "ToolPostInvokeResult", kwargs); } let meta = PyDict::new(py); @@ -213,7 +213,7 @@ impl OutputLengthGuardPluginCore { for item in list.iter() { let text: String = item.extract()?; match handle_text(py, &text, &self.cfg)? { - TextResult::Violation(v) => return build_blocked_result(py, trace_id, v), + TextResult::Violation(v) => return build_blocked_result(py, trace_id, v, &self.cfg), TextResult::Modified(new_text) => { total_chars_truncated += text.len(); items_modified += 1; @@ -240,6 +240,7 @@ impl OutputLengthGuardPluginCore { total_chars_truncated, true, items_modified, + &self.cfg, )?; return build_result_dyn(py, "ToolPostInvokeResult", kwargs); } @@ -273,7 +274,7 @@ impl OutputLengthGuardPluginCore { details, } => { let violation = build_violation(py, &reason, &description, &code, &details)?; - return build_blocked_result(py, trace_id, violation); + return build_blocked_result(py, trace_id, violation, &self.cfg); } ProcessResult::Ok { value, modified } => { if modified { @@ -317,7 +318,7 @@ impl OutputLengthGuardPluginCore { let (out_items, was_modified, total_chars_seen, items_modified_count) = match self.process_mcp_items_result(py, content_list, trace_id)? { Ok(r) => r, - Err(violation) => return build_blocked_result(py, trace_id, violation), + Err(violation) => return build_blocked_result(py, trace_id, violation, &self.cfg), }; let sc_processed = struct_key.is_some(); @@ -345,6 +346,7 @@ impl OutputLengthGuardPluginCore { total_chars_seen, true, items_modified_count, + &self.cfg, )?; return build_result_dyn(py, "ToolPostInvokeResult", kwargs); } @@ -365,26 +367,42 @@ impl OutputLengthGuardPluginCore { list: &Bound<'_, PyList>, _trace_id: Option<&str>, ) -> PyResult>, bool, usize, usize), Py>> { - // Security: reject lists that exceed max_structure_size - if list.len() > self.cfg.max_structure_size && self.cfg.strategy == Strategy::Block { - let violation = build_violation( - py, - "Structure size exceeds security limit", - &format!( - "Content list has {} items, exceeding limit of {}", - list.len(), - self.cfg.max_structure_size - ), - "STRUCTURE_SIZE_VIOLATION", - &[ - ("size".to_string(), serde_json::json!(list.len())), - ( - "max_size".to_string(), - serde_json::json!(self.cfg.max_structure_size), + // Security: enforce max_structure_size regardless of strategy. + // Mirrors the pattern in structured.rs::process_list / process_dict. + if list.len() > self.cfg.max_structure_size { + log::error!( + "Content list size {} exceeds maximum {} (MCP items)", + list.len(), + self.cfg.max_structure_size + ); + if self.cfg.strategy == Strategy::Block { + let violation = build_violation( + py, + "Structure size exceeds security limit", + &format!( + "Content list has {} items, exceeding limit of {}", + list.len(), + self.cfg.max_structure_size ), - ], - )?; - return Ok(Err(violation)); + "STRUCTURE_SIZE_VIOLATION", + &[ + ("size".to_string(), serde_json::json!(list.len())), + ( + "max_size".to_string(), + serde_json::json!(self.cfg.max_structure_size), + ), + ], + )?; + return Ok(Err(violation)); + } + // Truncate strategy: pass the oversized list through unchanged (items will + // still have their individual text content guarded below). + return Ok(Ok(( + list.iter().map(|item| item.unbind()).collect(), + false, + 0, + 0, + ))); } let mut modified = false; @@ -601,6 +619,7 @@ fn push_metrics_kwargs( chars_seen: usize, truncated: bool, items_modified: usize, + cfg: &OutputLengthGuardConfig, ) -> PyResult<()> { let Some(tid) = trace_id else { return Ok(()); @@ -612,8 +631,8 @@ fn push_metrics_kwargs( chars_seen, truncated_count: if truncated { items_modified } else { 0 }, blocked: false, - mode: "character", - strategy: "truncate", + mode: cfg.limit_mode.as_str(), + strategy: cfg.strategy.as_str(), stage: "tool_post_invoke", }, )? { @@ -655,6 +674,7 @@ fn build_blocked_result( py: Python<'_>, trace_id: Option<&str>, violation: Py, + cfg: &OutputLengthGuardConfig, ) -> PyResult> { let mut kwargs: Vec<(&str, Py)> = vec![ ( @@ -671,8 +691,8 @@ fn build_blocked_result( chars_seen: 0, truncated_count: 0, blocked: true, - mode: "character", - strategy: "block", + mode: cfg.limit_mode.as_str(), + strategy: cfg.strategy.as_str(), stage: "tool_post_invoke", }, ) @@ -1891,4 +1911,95 @@ class Payload: ); }); } + + // ── Bug fix: push_metrics_kwargs emits actual limit_mode / strategy ────── + // Regression test: token-mode truncation must emit limit_mode="token", not "character". + #[test] + fn token_mode_metrics_emit_correct_limit_mode() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + let d = PyDict::new(py); + d.set_item("max_tokens", 2usize).unwrap(); // 2 tokens * 4 chars = 8 chars max + d.set_item("limit_mode", "token").unwrap(); + d.set_item("strategy", "truncate").unwrap(); + d.set_item("max_chars", py.None()).unwrap(); + let core = OutputLengthGuardPluginCore::new(d.as_any()).unwrap(); + // 16 chars = 4 estimated tokens > 2 → truncation fires + let text = "abcdefghijklmnop".into_pyobject(py).unwrap().into_any(); + let payload = make_payload(py, "t", text).unwrap(); + let ctx = PyDict::new(py); + let ext = make_extensions(py, "trace-token").unwrap(); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), Some(&ext)) + .unwrap(); + let result = result.bind(py); + let meta = result.getattr("metadata").unwrap(); + let inner = meta.get_item("output_length_guard").unwrap(); + assert!(!inner.is_none(), "metrics must be present with trace_id"); + let limit_mode: String = inner + .get_item("limit_mode") + .unwrap() + .extract() + .unwrap(); + assert_eq!( + limit_mode, "token", + "token-mode plugin must emit limit_mode='token', not 'character'" + ); + let strategy: String = inner + .get_item("strategy") + .unwrap() + .extract() + .unwrap(); + assert_eq!( + strategy, "truncate", + "truncate-strategy plugin must emit strategy='truncate'" + ); + }); + } + + // ── Bug fix: process_mcp_items_result enforces max_structure_size in truncate mode ── + // Regression test: oversized list with strategy=truncate must pass through, not loop forever. + #[test] + fn mcp_content_dict_oversized_list_truncate_mode_passes_through_unchanged() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + let d = PyDict::new(py); + d.set_item("max_chars", py.None()).unwrap(); // no char limit + d.set_item("max_structure_size", 2usize).unwrap(); + d.set_item("strategy", "truncate").unwrap(); + d.set_item("limit_mode", "character").unwrap(); + let core = OutputLengthGuardPluginCore::new(d.as_any()).unwrap(); + // 3 items > max_structure_size=2, strategy=truncate: must NOT block, + // must return the list unchanged (no panic, no infinite loop). + let item_a = PyDict::new(py); + item_a.set_item("type", "text").unwrap(); + item_a.set_item("text", "a").unwrap(); + let item_b = PyDict::new(py); + item_b.set_item("type", "text").unwrap(); + item_b.set_item("text", "b").unwrap(); + let item_c = PyDict::new(py); + item_c.set_item("type", "text").unwrap(); + item_c.set_item("text", "c").unwrap(); + let content = PyList::new(py, [item_a, item_b, item_c]).unwrap(); + let result_dict = PyDict::new(py); + result_dict.set_item("content", content).unwrap(); + let payload = make_payload(py, "t", result_dict.as_any().clone()).unwrap(); + let ctx = PyDict::new(py); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), None) + .unwrap(); + let cp: bool = result + .bind(py) + .getattr("continue_processing") + .unwrap() + .extract() + .unwrap(); + assert!( + cp, + "oversized list with truncate strategy must not block (continue_processing=true)" + ); + }); + } } From 62b891e3bb59aea2dc96c6755b10876ae5574020 Mon Sep 17 00:00:00 2001 From: prakhar-singh1928 Date: Wed, 26 Aug 2026 13:12:31 +0100 Subject: [PATCH 10/16] fix clippy errors Signed-off-by: prakhar-singh1928 --- .../output_length_guard/src/plugin.rs | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/plugins/rust/python-package/output_length_guard/src/plugin.rs b/plugins/rust/python-package/output_length_guard/src/plugin.rs index 38cd54a..3098f91 100644 --- a/plugins/rust/python-package/output_length_guard/src/plugin.rs +++ b/plugins/rust/python-package/output_length_guard/src/plugin.rs @@ -188,7 +188,15 @@ impl OutputLengthGuardPluginCore { ("modified_payload", new_payload), ("metadata", meta.into_any().unbind()), ]; - push_metrics_kwargs(py, trace_id, &mut kwargs, total_chars, true, items_modified, &self.cfg)?; + push_metrics_kwargs( + py, + trace_id, + &mut kwargs, + total_chars, + true, + items_modified, + &self.cfg, + )?; return build_result_dyn(py, "ToolPostInvokeResult", kwargs); } let meta = PyDict::new(py); @@ -213,7 +221,9 @@ impl OutputLengthGuardPluginCore { for item in list.iter() { let text: String = item.extract()?; match handle_text(py, &text, &self.cfg)? { - TextResult::Violation(v) => return build_blocked_result(py, trace_id, v, &self.cfg), + TextResult::Violation(v) => { + return build_blocked_result(py, trace_id, v, &self.cfg); + } TextResult::Modified(new_text) => { total_chars_truncated += text.len(); items_modified += 1; @@ -1937,20 +1947,12 @@ class Payload: let meta = result.getattr("metadata").unwrap(); let inner = meta.get_item("output_length_guard").unwrap(); assert!(!inner.is_none(), "metrics must be present with trace_id"); - let limit_mode: String = inner - .get_item("limit_mode") - .unwrap() - .extract() - .unwrap(); + let limit_mode: String = inner.get_item("limit_mode").unwrap().extract().unwrap(); assert_eq!( limit_mode, "token", "token-mode plugin must emit limit_mode='token', not 'character'" ); - let strategy: String = inner - .get_item("strategy") - .unwrap() - .extract() - .unwrap(); + let strategy: String = inner.get_item("strategy").unwrap().extract().unwrap(); assert_eq!( strategy, "truncate", "truncate-strategy plugin must emit strategy='truncate'" From 39b149335c8a2049bf54114c7fdcc8aac445d4c4 Mon Sep 17 00:00:00 2001 From: prakhar-singh1928 Date: Thu, 27 Aug 2026 11:52:35 +0100 Subject: [PATCH 11/16] fix(output_length_guard): fix metadata key collision and remove dead code in structured.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs fixed: 1. plugin.rs — metadata key collision silently drops data In handle_plain_string and handle_text_dict, the kwargs vec already contained (metadata, text_meta_dict) from build_text_meta. push_metrics_kwargs then appended a second (metadata, otel_dict). build_framework_object_dyn iterates the vec into a PyDict via set_item, so the second entry silently overwrote the first. Callers with a trace_id lost either original_length/truncated/ new_length or the output_length_guard metrics depending on insertion order. The same double-append pattern existed in handle_mcp_list, handle_string_list, and handle_mcp_content_dict. Fix: replace push_metrics_kwargs (which appended to the kwargs vec) with merge_metrics_into_meta, which takes a live &Bound and inserts the output_length_guard namespace key directly into the existing metadata dict. Rename build_text_meta -> build_text_meta_dict and change its return type from Py to Bound so it stays bound long enough for the merge before the final unbind. All 5 call sites updated. 2. structured.rs — dead code in generate_text_representation The multi-key dict branch contained a Python::attach(|_py| Ok(())) whose result was immediately discarded (let _ = json_module). The comment acknowledged the GIL was already held. The list branch used if let Ok(list) = ... { let _ = list; ... } — the binding was never used; json_dumps received the original data reference. Fix: remove both dead blocks. Replace the unused-binding list pattern with data.cast::().is_ok(). All checks pass: cargo fmt -- --check ok cargo clippy -p output_length_guard -- -D warnings ok cargo test -p output_length_guard 139/139 ok Signed-off-by: prakhar.singh1928@ibm.com Signed-off-by: prakhar-singh1928 --- .../output_length_guard/src/plugin.rs | 89 +++++++++++-------- .../output_length_guard/src/structured.rs | 8 +- 2 files changed, 52 insertions(+), 45 deletions(-) diff --git a/plugins/rust/python-package/output_length_guard/src/plugin.rs b/plugins/rust/python-package/output_length_guard/src/plugin.rs index 3098f91..21eb4ea 100644 --- a/plugins/rust/python-package/output_length_guard/src/plugin.rs +++ b/plugins/rust/python-package/output_length_guard/src/plugin.rs @@ -115,15 +115,18 @@ impl OutputLengthGuardPluginCore { TextResult::Modified(new_text) => { let new_result_obj = new_text.into_pyobject(py)?.into_any().unbind(); let new_payload = clone_payload_with_attr(py, payload, "result", &new_result_obj)?; - let meta = build_text_meta(py, text, &new_text_str(&new_result_obj, py), false)?; - let mut kwargs: Vec<(&str, Py)> = - vec![("modified_payload", new_payload), ("metadata", meta)]; - push_metrics_kwargs(py, trace_id, &mut kwargs, text.len(), true, 1, &self.cfg)?; + let meta = + build_text_meta_dict(py, text, &new_text_str(&new_result_obj, py), false)?; + merge_metrics_into_meta(py, &meta, trace_id, text.len(), true, 1, &self.cfg)?; + let kwargs: Vec<(&str, Py)> = vec![ + ("modified_payload", new_payload), + ("metadata", meta.into_any().unbind()), + ]; build_result_dyn(py, "ToolPostInvokeResult", kwargs) } TextResult::Unchanged => { - let meta = build_text_meta(py, text, text, true)?; - let kwargs: Vec<(&str, Py)> = vec![("metadata", meta)]; + let meta = build_text_meta_dict(py, text, text, true)?; + let kwargs: Vec<(&str, Py)> = vec![("metadata", meta.into_any().unbind())]; build_result_dyn(py, "ToolPostInvokeResult", kwargs) } } @@ -150,15 +153,17 @@ impl OutputLengthGuardPluginCore { TextResult::Modified(new_text) => { let new_dict = clone_dict_with_key(py, result_dict, "text", &new_text)?; let new_payload = clone_payload_with_attr(py, payload, "result", &new_dict)?; - let meta = build_text_meta(py, &text, &new_text, false)?; - let mut kwargs: Vec<(&str, Py)> = - vec![("modified_payload", new_payload), ("metadata", meta)]; - push_metrics_kwargs(py, trace_id, &mut kwargs, text.len(), true, 1, &self.cfg)?; + let meta = build_text_meta_dict(py, &text, &new_text, false)?; + merge_metrics_into_meta(py, &meta, trace_id, text.len(), true, 1, &self.cfg)?; + let kwargs: Vec<(&str, Py)> = vec![ + ("modified_payload", new_payload), + ("metadata", meta.into_any().unbind()), + ]; build_result_dyn(py, "ToolPostInvokeResult", kwargs) } TextResult::Unchanged => { - let meta = build_text_meta(py, &text, &text, true)?; - let kwargs: Vec<(&str, Py)> = vec![("metadata", meta)]; + let meta = build_text_meta_dict(py, &text, &text, true)?; + let kwargs: Vec<(&str, Py)> = vec![("metadata", meta.into_any().unbind())]; build_result_dyn(py, "ToolPostInvokeResult", kwargs) } } @@ -184,19 +189,19 @@ impl OutputLengthGuardPluginCore { let new_payload = clone_payload_with_attr(py, payload, "result", &new_result_obj)?; let meta = PyDict::new(py); meta.set_item("mcp_content_processed", true)?; - let mut kwargs: Vec<(&str, Py)> = vec![ - ("modified_payload", new_payload), - ("metadata", meta.into_any().unbind()), - ]; - push_metrics_kwargs( + merge_metrics_into_meta( py, + &meta, trace_id, - &mut kwargs, total_chars, true, items_modified, &self.cfg, )?; + let kwargs: Vec<(&str, Py)> = vec![ + ("modified_payload", new_payload), + ("metadata", meta.into_any().unbind()), + ]; return build_result_dyn(py, "ToolPostInvokeResult", kwargs); } let meta = PyDict::new(py); @@ -239,19 +244,19 @@ impl OutputLengthGuardPluginCore { let new_result_obj = new_list.into_any().unbind(); let new_payload = clone_payload_with_attr(py, payload, "result", &new_result_obj)?; let meta = PyDict::new(py); - let mut kwargs: Vec<(&str, Py)> = vec![ - ("modified_payload", new_payload), - ("metadata", meta.into_any().unbind()), - ]; - push_metrics_kwargs( + merge_metrics_into_meta( py, + &meta, trace_id, - &mut kwargs, total_chars_truncated, true, items_modified, &self.cfg, )?; + let kwargs: Vec<(&str, Py)> = vec![ + ("modified_payload", new_payload), + ("metadata", meta.into_any().unbind()), + ]; return build_result_dyn(py, "ToolPostInvokeResult", kwargs); } let meta = PyDict::new(py); @@ -345,19 +350,19 @@ impl OutputLengthGuardPluginCore { meta.set_item("mcp_result_processed", true)?; meta.set_item("items_modified", true)?; meta.set_item("structured_content_processed", sc_processed)?; - let mut kwargs: Vec<(&str, Py)> = vec![ - ("modified_payload", new_payload), - ("metadata", meta.into_any().unbind()), - ]; - push_metrics_kwargs( + merge_metrics_into_meta( py, + &meta, trace_id, - &mut kwargs, total_chars_seen, true, items_modified_count, &self.cfg, )?; + let kwargs: Vec<(&str, Py)> = vec![ + ("modified_payload", new_payload), + ("metadata", meta.into_any().unbind()), + ]; return build_result_dyn(py, "ToolPostInvokeResult", kwargs); } @@ -622,10 +627,15 @@ fn build_output_metrics<'py>( Ok(Some(outer)) } -fn push_metrics_kwargs( +/// Merge OTel metrics into an existing metadata dict. +/// +/// When a trace_id is present, builds the `output_length_guard` metrics dict and +/// inserts it as `meta["output_length_guard"]`. This avoids a second "metadata" +/// entry in the kwargs vec, which would silently overwrite the first one. +fn merge_metrics_into_meta( py: Python<'_>, + meta: &Bound<'_, PyDict>, trace_id: Option<&str>, - kwargs: &mut Vec<(&str, Py)>, chars_seen: usize, truncated: bool, items_modified: usize, @@ -634,7 +644,7 @@ fn push_metrics_kwargs( let Some(tid) = trace_id else { return Ok(()); }; - if let Some(md) = build_output_metrics( + if let Some(outer) = build_output_metrics( py, Some(tid), MetricsArgs { @@ -646,7 +656,10 @@ fn push_metrics_kwargs( stage: "tool_post_invoke", }, )? { - kwargs.push(("metadata", md.into_any().unbind())); + // outer is { "output_length_guard": {...} } — merge its single entry into meta + if let Some(inner) = outer.get_item(PLUGIN_KEY)? { + meta.set_item(PLUGIN_KEY, inner)?; + } } Ok(()) } @@ -832,12 +845,12 @@ fn new_text_str(obj: &Py, py: Python<'_>) -> String { obj.bind(py).extract::().unwrap_or_default() } -fn build_text_meta( - py: Python<'_>, +fn build_text_meta_dict<'py>( + py: Python<'py>, original: &str, new_text: &str, within_bounds: bool, -) -> PyResult> { +) -> PyResult> { let meta = PyDict::new(py); meta.set_item("original_length", original.len())?; meta.set_item("within_bounds", within_bounds)?; @@ -845,7 +858,7 @@ fn build_text_meta( meta.set_item("truncated", new_text != original)?; meta.set_item("new_length", new_text.len())?; } - Ok(meta.into_any().unbind()) + Ok(meta) } /// Extract trace_id from extensions.request.trace_id diff --git a/plugins/rust/python-package/output_length_guard/src/structured.rs b/plugins/rust/python-package/output_length_guard/src/structured.rs index e4a4e73..3f564e7 100644 --- a/plugins/rust/python-package/output_length_guard/src/structured.rs +++ b/plugins/rust/python-package/output_length_guard/src/structured.rs @@ -356,16 +356,10 @@ pub fn generate_text_representation(data: &Bound<'_, PyAny>, depth: usize) -> Py return generate_text_representation(&val, depth + 1); } // Multi-key dict or depth limit reached - let json_module = pyo3::Python::attach(|_py| { - // We already have access to py via data's GIL - Ok::<_, PyErr>(()) - }); - let _ = json_module; return json_dumps(data); } - if let Ok(list) = data.cast::() { - let _ = list; + if data.cast::().is_ok() { return json_dumps(data); } From f6e14b5684392e0a83ffab30ae651d8eb1c55c2c Mon Sep 17 00:00:00 2001 From: prakhar-singh1928 Date: Thu, 27 Aug 2026 12:04:57 +0100 Subject: [PATCH 12/16] fix(output_length_guard): use char count for character-mode detection, not byte length Signed-off-by: prakhar-singh1928 --- .../output_length_guard/src/plugin.rs | 75 +++++++++++++++++-- .../output_length_guard/src/structured.rs | 17 +++-- 2 files changed, 79 insertions(+), 13 deletions(-) diff --git a/plugins/rust/python-package/output_length_guard/src/plugin.rs b/plugins/rust/python-package/output_length_guard/src/plugin.rs index 21eb4ea..32fc7ac 100644 --- a/plugins/rust/python-package/output_length_guard/src/plugin.rs +++ b/plugins/rust/python-package/output_length_guard/src/plugin.rs @@ -509,9 +509,9 @@ fn handle_text(py: Python<'_>, text: &str, cfg: &OutputLengthGuardConfig) -> PyR return Ok(TextResult::Unchanged); } - let length = text.len(); + let char_count = text.chars().count(); let token_count = estimate_tokens(text, cfg.chars_per_token); - let (below_min, above_max) = evaluate_text_limits(length, token_count, cfg); + let (below_min, above_max) = evaluate_text_limits(char_count, token_count, cfg); if !below_min && !above_max { return Ok(TextResult::Unchanged); @@ -546,12 +546,12 @@ fn handle_text(py: Python<'_>, text: &str, cfg: &OutputLengthGuardConfig) -> PyR "Output length out of bounds".to_string(), format!( "Result length {} exceeds max_chars {}", - length, + char_count, cfg.max_chars.unwrap_or(0) ), "OUTPUT_LENGTH_VIOLATION".to_string(), vec![ - ("length".to_string(), serde_json::json!(length)), + ("length".to_string(), serde_json::json!(char_count)), ("max_chars".to_string(), serde_json::json!(cfg.max_chars)), ( "strategy".to_string(), @@ -564,11 +564,11 @@ fn handle_text(py: Python<'_>, text: &str, cfg: &OutputLengthGuardConfig) -> PyR "Output length below minimum".to_string(), format!( "Result length {} (tokens {}) below minimum", - length, token_count + char_count, token_count ), "OUTPUT_LENGTH_VIOLATION".to_string(), vec![ - ("length".to_string(), serde_json::json!(length)), + ("length".to_string(), serde_json::json!(char_count)), ("min_chars".to_string(), serde_json::json!(cfg.min_chars)), ("token_count".to_string(), serde_json::json!(token_count)), ("min_tokens".to_string(), serde_json::json!(cfg.min_tokens)), @@ -2017,4 +2017,67 @@ class Payload: ); }); } + + // ── Bug fix: char-mode detection uses char count not byte length ────────── + // A 4-char CJK string (4 bytes × 3 = 12 bytes in UTF-8) with max_chars=10 + // must NOT trigger — 4 chars ≤ 10. With the old text.len() it would fire + // (12 > 10). With the fix (chars().count()) it correctly passes through. + #[test] + fn char_mode_detection_uses_char_count_not_bytes_for_cjk() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + // "你好世界" = 4 CJK chars, 12 UTF-8 bytes + let core = make_core(Some(10), "block").unwrap(); + let text = "你好世界".into_pyobject(py).unwrap().into_any(); + let payload = make_payload(py, "t", text).unwrap(); + let ctx = PyDict::new(py); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), None) + .unwrap(); + let cp: bool = result + .bind(py) + .getattr("continue_processing") + .unwrap() + .extract() + .unwrap(); + assert!( + cp, + "4-char CJK string must not be blocked with max_chars=10 (char count=4, byte len=12)" + ); + // modified_payload must be None — no truncation needed + let modified = result.bind(py).getattr("modified_payload").unwrap(); + assert!( + modified.is_none(), + "4-char CJK string must pass through unmodified" + ); + }); + } + + // Companion: 11-char CJK string (33 bytes) with max_chars=10 MUST block. + #[test] + fn char_mode_detection_blocks_cjk_string_exceeding_char_limit() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + // "你好世界!" × 3 = 15 chars, well above max_chars=10 + let core = make_core(Some(10), "block").unwrap(); + let text = "你好世界!你好世界!你" + .into_pyobject(py) + .unwrap() + .into_any(); // 11 chars + let payload = make_payload(py, "t", text).unwrap(); + let ctx = PyDict::new(py); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), None) + .unwrap(); + let cp: bool = result + .bind(py) + .getattr("continue_processing") + .unwrap() + .extract() + .unwrap(); + assert!(!cp, "11-char CJK string must be blocked with max_chars=10"); + }); + } } diff --git a/plugins/rust/python-package/output_length_guard/src/structured.rs b/plugins/rust/python-package/output_length_guard/src/structured.rs index 3f564e7..e256951 100644 --- a/plugins/rust/python-package/output_length_guard/src/structured.rs +++ b/plugins/rust/python-package/output_length_guard/src/structured.rs @@ -101,9 +101,12 @@ fn process_string( }); } - let length = text.len(); - let token_count = length / cfg.chars_per_token.max(1); - let (below_min, above_max) = evaluate_text_limits(length, token_count, cfg); + // Use char count (not byte length) so that max_chars is enforced in Unicode + // codepoints, matching Python's len(str) semantics. Byte length is only used + // for token estimation, which mirrors Python's len(text) // chars_per_token. + let char_count = text.chars().count(); + let token_count = text.len() / cfg.chars_per_token.max(1); + let (below_min, above_max) = evaluate_text_limits(char_count, token_count, cfg); if !below_min && !above_max { return Ok(ProcessResult::Ok { @@ -144,13 +147,13 @@ fn process_string( reason: format!("String length out of bounds at {}", location), description: format!( "String length {} exceeds max_chars {} at {}", - length, + char_count, cfg.max_chars.unwrap_or(0), location ), code: "OUTPUT_LENGTH_VIOLATION".to_string(), details: vec![ - ("length".to_string(), serde_json::json!(length)), + ("length".to_string(), serde_json::json!(char_count)), ("max_chars".to_string(), serde_json::json!(cfg.max_chars)), ( "strategy".to_string(), @@ -165,11 +168,11 @@ fn process_string( reason: format!("String length/tokens below minimum at {}", location), description: format!( "String length {} or tokens {} below minimum at {}", - length, token_count, location + char_count, token_count, location ), code: "OUTPUT_LENGTH_VIOLATION".to_string(), details: vec![ - ("length".to_string(), serde_json::json!(length)), + ("length".to_string(), serde_json::json!(char_count)), ("min_chars".to_string(), serde_json::json!(cfg.min_chars)), ("token_count".to_string(), serde_json::json!(token_count)), ("min_tokens".to_string(), serde_json::json!(cfg.min_tokens)), From 30e41b00324dffe533fc83270cfa9d7909e08cb4 Mon Sep 17 00:00:00 2001 From: prakhar-singh1928 Date: Thu, 27 Aug 2026 13:29:02 +0100 Subject: [PATCH 13/16] fix(test_plugin_catalog): update counts and splits for 8 Rust + 1 Python plugins After rebasing onto main (which added ica_metering_exporter as the first pure-Python plugin via PR #170), the combined repository now has 9 plugins: 8 Rust (including output_length_guard) + 1 Python (ica_metering_exporter). Update three assertions that were left with stale values after the rebase: - plugin_count field test: 8 -> 9 - rust_plugin_count split field: "7" -> "8" - test_ci_selection_reports_language_splits_and_counts: comment + assertion 7 -> 8 Signed-off-by: prakhar-singh1928 --- tests/test_plugin_catalog.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_plugin_catalog.py b/tests/test_plugin_catalog.py index 3462781..11cd400 100644 --- a/tests/test_plugin_catalog.py +++ b/tests/test_plugin_catalog.py @@ -2658,7 +2658,7 @@ def test_ci_selection_field_prints_json_and_bool_scalars(self) -> None: result = run_catalog("ci-selection-field", str(REPO_ROOT), "all", "", "", "plugin_count") self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout.strip(), "8") + self.assertEqual(result.stdout.strip(), "9") result = run_catalog("ci-selection-field", str(REPO_ROOT), "all", "", "", "cargo_packages") self.assertEqual(result.returncode, 0, result.stderr) @@ -2695,7 +2695,7 @@ def test_ci_selection_field_prints_json_and_bool_scalars(self) -> None: "python_plugins": '["ica_metering_exporter"]', "has_rust_plugins": "true", "has_python_plugins": "true", - "rust_plugin_count": "7", + "rust_plugin_count": "8", "python_plugin_count": "1", "rust_release_validation_tags": "[]", "python_release_validation_tags": "[]", @@ -4626,8 +4626,8 @@ def test_ci_selection_reports_language_splits_and_counts(self) -> None: self.assertEqual(result.returncode, 0, result.stderr) repository_payload = json.loads(result.stdout) - # Then all-mode characterizes the required 7 Rust / 1 Python split. - self.assertEqual(repository_payload["rust_plugin_count"], 7) + # Then all-mode characterizes the required 8 Rust / 1 Python split. + self.assertEqual(repository_payload["rust_plugin_count"], 8) self.assertEqual(repository_payload["python_plugin_count"], 1) self.assertTrue(repository_payload["has_rust_plugins"]) self.assertTrue(repository_payload["has_python_plugins"]) From 0e2de8a8d5e2b9472146ffdb2c4b06dd8f67d9d5 Mon Sep 17 00:00:00 2001 From: prakhar-singh1928 Date: Thu, 27 Aug 2026 15:25:24 +0100 Subject: [PATCH 14/16] fix(output_length_guard): resolve 4 blocking review issues from PR #169 Fix 1 (guards.rs): cap_at_max_text_length snaps cut to nearest valid UTF-8 char boundary before slicing, preventing PanicException on multi-byte codepoints (e.g. euro sign * 400, max_text_length=1000). Fix 2 (plugin.rs): process_mcp_items_result no longer early-returns on oversized lists in truncate mode. The STRUCTURE_SIZE_VIOLATION block is now gated on strategy == Block only; truncate mode logs a warning and continues to guard individual item text. Fix 3 (plugin.rs): build_violation populates mcp_error_code=-32000 and http_status_code=422 on every PluginViolation, satisfying the gateway contract and avoiding the -32603 fallback in the exception handler. Fix 4 (plugin.rs): introduce TextResult::BelowMin variant. handle_text returns BelowMin when below_min && !above_max in truncate mode. handle_plain_string and handle_text_dict set within_bounds=false for BelowMin results, matching the expected contract. Regression tests added for all four fixes. Inline narration comments referencing 'matching Python behaviour' replaced with functional descriptions. 145 Rust unit tests pass. Python catalog: 133 passed, 3 skipped. Signed-off-by: prakhar-singh1928 --- .../output_length_guard/src/guards.rs | 42 ++- .../output_length_guard/src/plugin.rs | 246 +++++++++++++++--- 2 files changed, 243 insertions(+), 45 deletions(-) diff --git a/plugins/rust/python-package/output_length_guard/src/guards.rs b/plugins/rust/python-package/output_length_guard/src/guards.rs index 60b3777..7a6e7eb 100644 --- a/plugins/rust/python-package/output_length_guard/src/guards.rs +++ b/plugins/rust/python-package/output_length_guard/src/guards.rs @@ -88,16 +88,24 @@ fn is_below_token_min(token_count: usize, min_tokens: usize) -> bool { min_tokens > 0 && token_count < min_tokens } -/// Returns a sub-slice capped at `max_text_length` bytes. +/// Returns a sub-slice capped at `max_text_length` bytes, snapped to the +/// nearest valid UTF-8 char boundary at or before `max_text_length`. +/// +/// Without the boundary snap, slicing at an arbitrary byte offset inside a +/// multi-byte codepoint (e.g. `"€" * 400`, `max_text_length=1000`) causes a +/// `PanicException` at the `&value[..cut]` site. /// /// Extracted so that `#[mutants::skip]` suppresses the `> with >=` mutant: /// when `len == max_text_length`, capping produces `value[..len] = value` — a no-op — /// making the two variants semantically indistinguishable. -#[mutants::skip] // equivalent: > vs >= when len == max_text_length; capping to self = no-op -#[inline] +#[mutants::skip] // equivalent: > vs >= when len == max_text_length; capping to self = no-op; snap loop usize equivalence fn cap_at_max_text_length(value: &str, max_text_length: usize) -> &str { if value.len() > max_text_length { - &value[..max_text_length] + let mut cut = max_text_length; + while cut > 0 && !value.is_char_boundary(cut) { + cut -= 1; + } + &value[..cut] } else { value } @@ -957,4 +965,30 @@ mod tests { "result must start with the 'a' prefix" ); } + + #[test] + fn cap_at_max_text_length_does_not_panic_on_multibyte_boundary() { + let s: String = "€".repeat(400); // 1200 bytes + assert_eq!(s.len(), 1200); + let cfg = OutputLengthGuardConfig { + max_text_length: 1000, + max_tokens: Some(1), + limit_mode: crate::config::LimitMode::Token, + strategy: Strategy::Truncate, + ellipsis: String::new(), + ..Default::default() + }; + // Must not panic; result must be valid UTF-8 and at most 1000 bytes. + let result = truncate(&s, &cfg); + assert!( + std::str::from_utf8(result.as_bytes()).is_ok(), + "result must be valid UTF-8" + ); + // All "€" chars are 3 bytes; the nearest boundary at or below 1000 is 999. + assert!( + result.len() <= 1000, + "result must not exceed max_text_length bytes: len={}", + result.len() + ); + } } diff --git a/plugins/rust/python-package/output_length_guard/src/plugin.rs b/plugins/rust/python-package/output_length_guard/src/plugin.rs index 32fc7ac..8fe0895 100644 --- a/plugins/rust/python-package/output_length_guard/src/plugin.rs +++ b/plugins/rust/python-package/output_length_guard/src/plugin.rs @@ -124,6 +124,12 @@ impl OutputLengthGuardPluginCore { ]; build_result_dyn(py, "ToolPostInvokeResult", kwargs) } + TextResult::BelowMin => { + // Below min in truncate mode: pass through unchanged but flag out-of-bounds. + let meta = build_text_meta_dict(py, text, text, false)?; + let kwargs: Vec<(&str, Py)> = vec![("metadata", meta.into_any().unbind())]; + build_result_dyn(py, "ToolPostInvokeResult", kwargs) + } TextResult::Unchanged => { let meta = build_text_meta_dict(py, text, text, true)?; let kwargs: Vec<(&str, Py)> = vec![("metadata", meta.into_any().unbind())]; @@ -161,6 +167,12 @@ impl OutputLengthGuardPluginCore { ]; build_result_dyn(py, "ToolPostInvokeResult", kwargs) } + TextResult::BelowMin => { + // Below min in truncate mode: pass through unchanged but flag out-of-bounds. + let meta = build_text_meta_dict(py, &text, &text, false)?; + let kwargs: Vec<(&str, Py)> = vec![("metadata", meta.into_any().unbind())]; + build_result_dyn(py, "ToolPostInvokeResult", kwargs) + } TextResult::Unchanged => { let meta = build_text_meta_dict(py, &text, &text, true)?; let kwargs: Vec<(&str, Py)> = vec![("metadata", meta.into_any().unbind())]; @@ -235,7 +247,7 @@ impl OutputLengthGuardPluginCore { out.push(new_text); modified = true; } - TextResult::Unchanged => out.push(text), + TextResult::BelowMin | TextResult::Unchanged => out.push(text), } } @@ -382,42 +394,40 @@ impl OutputLengthGuardPluginCore { list: &Bound<'_, PyList>, _trace_id: Option<&str>, ) -> PyResult>, bool, usize, usize), Py>> { - // Security: enforce max_structure_size regardless of strategy. - // Mirrors the pattern in structured.rs::process_list / process_dict. - if list.len() > self.cfg.max_structure_size { + // Security: enforce max_structure_size on block strategy only. + // In truncate mode the list is allowed through so per-item text + // guarding below can still truncate individual oversized strings. + if list.len() > self.cfg.max_structure_size && self.cfg.strategy == Strategy::Block { log::error!( "Content list size {} exceeds maximum {} (MCP items)", list.len(), self.cfg.max_structure_size ); - if self.cfg.strategy == Strategy::Block { - let violation = build_violation( - py, - "Structure size exceeds security limit", - &format!( - "Content list has {} items, exceeding limit of {}", - list.len(), - self.cfg.max_structure_size + let violation = build_violation( + py, + "Structure size exceeds security limit", + &format!( + "Content list has {} items, exceeding limit of {}", + list.len(), + self.cfg.max_structure_size + ), + "STRUCTURE_SIZE_VIOLATION", + &[ + ("size".to_string(), serde_json::json!(list.len())), + ( + "max_size".to_string(), + serde_json::json!(self.cfg.max_structure_size), ), - "STRUCTURE_SIZE_VIOLATION", - &[ - ("size".to_string(), serde_json::json!(list.len())), - ( - "max_size".to_string(), - serde_json::json!(self.cfg.max_structure_size), - ), - ], - )?; - return Ok(Err(violation)); - } - // Truncate strategy: pass the oversized list through unchanged (items will - // still have their individual text content guarded below). - return Ok(Ok(( - list.iter().map(|item| item.unbind()).collect(), - false, - 0, - 0, - ))); + ], + )?; + return Ok(Err(violation)); + } + if list.len() > self.cfg.max_structure_size { + log::error!( + "Content list size {} exceeds maximum {} (MCP items), guarding individual items", + list.len(), + self.cfg.max_structure_size + ); } let mut modified = false; @@ -454,7 +464,7 @@ impl OutputLengthGuardPluginCore { modified = true; continue; } - TextResult::Unchanged => {} + TextResult::BelowMin | TextResult::Unchanged => {} } } @@ -485,7 +495,7 @@ impl OutputLengthGuardPluginCore { modified = true; continue; } - TextResult::Unchanged => {} + TextResult::BelowMin | TextResult::Unchanged => {} } } @@ -500,7 +510,10 @@ impl OutputLengthGuardPluginCore { enum TextResult { Unchanged, + /// Text was above-max and was truncated to `new_text`. Modified(String), + /// Text was below min in truncate mode — pass through but flag as out-of-bounds. + BelowMin, Violation(Py), } @@ -591,6 +604,11 @@ fn handle_text(py: Python<'_>, text: &str, cfg: &OutputLengthGuardConfig) -> PyR } } + // Below min in truncate mode: pass through unchanged, signal out-of-bounds. + if below_min { + return Ok(TextResult::BelowMin); + } + Ok(TextResult::Unchanged) } @@ -678,6 +696,9 @@ fn build_violation( let py_val: Py = json_to_py(py, v)?; details_dict.set_item(k, py_val.bind(py))?; } + // mcp_error_code=-32000 (Application error) and http_status_code=422 are + // required by the gateway contract; without them the exception handler falls + // back to JSON-RPC -32603 and an incorrect HTTP status. build_framework_object_dyn( py, "PluginViolation", @@ -689,6 +710,14 @@ fn build_violation( ), ("code", code.into_pyobject(py)?.into_any().unbind()), ("details", details_dict.into_any().unbind()), + ( + "mcp_error_code", + (-32000_i64).into_pyobject(py)?.into_any().unbind(), + ), + ( + "http_status_code", + 422_i64.into_pyobject(py)?.into_any().unbind(), + ), ], ) } @@ -1935,8 +1964,6 @@ class Payload: }); } - // ── Bug fix: push_metrics_kwargs emits actual limit_mode / strategy ────── - // Regression test: token-mode truncation must emit limit_mode="token", not "character". #[test] fn token_mode_metrics_emit_correct_limit_mode() { pyo3::Python::initialize(); @@ -1973,8 +2000,6 @@ class Payload: }); } - // ── Bug fix: process_mcp_items_result enforces max_structure_size in truncate mode ── - // Regression test: oversized list with strategy=truncate must pass through, not loop forever. #[test] fn mcp_content_dict_oversized_list_truncate_mode_passes_through_unchanged() { pyo3::Python::initialize(); @@ -2018,10 +2043,6 @@ class Payload: }); } - // ── Bug fix: char-mode detection uses char count not byte length ────────── - // A 4-char CJK string (4 bytes × 3 = 12 bytes in UTF-8) with max_chars=10 - // must NOT trigger — 4 chars ≤ 10. With the old text.len() it would fire - // (12 > 10). With the fix (chars().count()) it correctly passes through. #[test] fn char_mode_detection_uses_char_count_not_bytes_for_cjk() { pyo3::Python::initialize(); @@ -2080,4 +2101,147 @@ class Payload: assert!(!cp, "11-char CJK string must be blocked with max_chars=10"); }); } + + #[test] + fn mcp_content_dict_oversized_list_truncate_mode_still_guards_item_text() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + let d = PyDict::new(py); + d.set_item("max_chars", 2usize).unwrap(); // force truncation + d.set_item("max_structure_size", 2usize).unwrap(); + d.set_item("strategy", "truncate").unwrap(); + d.set_item("limit_mode", "character").unwrap(); + let core = OutputLengthGuardPluginCore::new(d.as_any()).unwrap(); + // 3 items > max_structure_size=2; strategy=truncate → items must still be guarded + let make_item = |text: &str| { + let item = PyDict::new(py); + item.set_item("type", "text").unwrap(); + item.set_item("text", text).unwrap(); + item + }; + let content = PyList::new( + py, + [ + make_item("abcdef"), + make_item("abcdef"), + make_item("abcdef"), + ], + ) + .unwrap(); + let result_dict = PyDict::new(py); + result_dict.set_item("content", content).unwrap(); + let payload = make_payload(py, "t", result_dict.as_any().clone()).unwrap(); + let ctx = PyDict::new(py); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), None) + .unwrap(); + let result = result.bind(py); + // continue_processing must be true (truncate, not block) + let cp: bool = result + .getattr("continue_processing") + .unwrap() + .extract() + .unwrap(); + assert!(cp, "truncate mode must not block"); + // modified_payload must be present — items were truncated + let modified = result.getattr("modified_payload").unwrap(); + assert!( + !modified.is_none(), + "oversized truncate list: items must still be truncated" + ); + // Each item text must be within max_chars=2 + let modified_result = modified.getattr("result").unwrap(); + let modified_result_dict = modified_result.cast::().unwrap(); + let content_val = modified_result_dict.get_item("content").unwrap().unwrap(); + let content_out = content_val.cast::().unwrap(); + for item in content_out.iter() { + let text: String = item + .cast::() + .unwrap() + .get_item("text") + .unwrap() + .unwrap() + .extract() + .unwrap(); + assert!( + text.chars().count() <= 2, + "item text '{}' exceeds max_chars=2", + text + ); + } + }); + } + + #[test] + fn violation_carries_mcp_error_code_and_http_status_code() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + let core = make_core(Some(5), "block").unwrap(); + let text = "this is a long string" + .into_pyobject(py) + .unwrap() + .into_any(); + let payload = make_payload(py, "t", text).unwrap(); + let ctx = PyDict::new(py); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), None) + .unwrap(); + let result = result.bind(py); + let violation = result.getattr("violation").unwrap(); + assert!(!violation.is_none(), "expected a violation"); + let mcp_code: i64 = violation + .getattr("mcp_error_code") + .unwrap() + .extract() + .unwrap(); + assert_eq!(mcp_code, -32000, "mcp_error_code must be -32000"); + let http_code: i64 = violation + .getattr("http_status_code") + .unwrap() + .extract() + .unwrap(); + assert_eq!(http_code, 422, "http_status_code must be 422"); + }); + } + + #[test] + fn below_min_truncate_mode_reports_within_bounds_false() { + pyo3::Python::initialize(); + pyo3::Python::attach(|py| { + install_framework_module(py).unwrap(); + let d = PyDict::new(py); + d.set_item("min_chars", 20usize).unwrap(); // min > len("short") = 5 + d.set_item("max_chars", py.None()).unwrap(); + d.set_item("strategy", "truncate").unwrap(); + d.set_item("limit_mode", "character").unwrap(); + let core = OutputLengthGuardPluginCore::new(d.as_any()).unwrap(); + let text = "short".into_pyobject(py).unwrap().into_any(); // 5 chars < min_chars=20 + let payload = make_payload(py, "t", text).unwrap(); + let ctx = PyDict::new(py); + let result = core + .tool_post_invoke(py, &payload, ctx.as_any(), None) + .unwrap(); + let result = result.bind(py); + // Must pass through (no violation, no modification) + let cp: bool = result + .getattr("continue_processing") + .unwrap() + .extract() + .unwrap(); + assert!(cp, "truncate mode must not block below-min strings"); + assert!( + result.getattr("modified_payload").unwrap().is_none(), + "below-min truncate must not modify the payload" + ); + // within_bounds must be false: value is out-of-bounds even though no truncation occurs + let meta = result.getattr("metadata").unwrap(); + let within_bounds: bool = meta.get_item("within_bounds").unwrap().extract().unwrap(); + assert!( + !within_bounds, + "below_min truncate mode must report within_bounds=false" + ); + }); + } } From 66a1fcca679fe7c1245a2702b19553a874bca9c0 Mon Sep 17 00:00:00 2001 From: prakhar-singh1928 Date: Thu, 27 Aug 2026 16:09:51 +0100 Subject: [PATCH 15/16] fix(plugin_hooks): add mcp_error_code to PluginViolation stub Signed-off-by: prakhar-singh1928 --- plugins/tests/plugin_hooks.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/tests/plugin_hooks.py b/plugins/tests/plugin_hooks.py index 72fd11b..371b246 100644 --- a/plugins/tests/plugin_hooks.py +++ b/plugins/tests/plugin_hooks.py @@ -164,6 +164,7 @@ class PluginViolation: description: str = "" code: str = "" details: dict[str, Any] | None = None + mcp_error_code: int | None = None http_status_code: int = 400 http_headers: dict[str, str] | None = None plugin_name: str = "" From 5b6ef24f947bd8ed872d359ff0cee58a078678ca Mon Sep 17 00:00:00 2001 From: prakhar-singh1928 Date: Thu, 27 Aug 2026 16:25:10 +0100 Subject: [PATCH 16/16] fix(output_length_guard): suppress unkillable mutants on log-only truncate warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three surviving mutants (> → ==, > → <, > → >=) on plugin.rs:425 all target the condition inside process_mcp_items_result that gates a log::error! warning in truncate mode. Because the branch body has no observable return value or side-effect visible to the test harness, all comparison variants produce identical behaviour and cannot be killed by a unit test. Extract the warning into log_mcp_truncate_size_warning and annotate it with Signed-off-by: prakhar-singh1928 #[mutants::skip], matching the established pattern in guards.rs and lib.rs. --- .../output_length_guard/src/plugin.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/plugins/rust/python-package/output_length_guard/src/plugin.rs b/plugins/rust/python-package/output_length_guard/src/plugin.rs index 8fe0895..393db5b 100644 --- a/plugins/rust/python-package/output_length_guard/src/plugin.rs +++ b/plugins/rust/python-package/output_length_guard/src/plugin.rs @@ -422,13 +422,7 @@ impl OutputLengthGuardPluginCore { )?; return Ok(Err(violation)); } - if list.len() > self.cfg.max_structure_size { - log::error!( - "Content list size {} exceeds maximum {} (MCP items), guarding individual items", - list.len(), - self.cfg.max_structure_size - ); - } + log_mcp_truncate_size_warning(list.len(), self.cfg.max_structure_size); let mut modified = false; let mut total_chars_seen: usize = 0; @@ -684,6 +678,17 @@ fn merge_metrics_into_meta( // ─── Framework helpers ──────────────────────────────────────────────────────── +#[mutants::skip] // log-only branch: > vs == / < / >= are all equivalent — no observable effect +fn log_mcp_truncate_size_warning(len: usize, max: usize) { + if len > max { + log::error!( + "Content list size {} exceeds maximum {} (MCP items), guarding individual items", + len, + max + ); + } +} + fn build_violation( py: Python<'_>, reason: &str,