diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 4200fdd..2a40601 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -59,7 +59,7 @@ jobs: - name: Upload production documentation if: >- github.ref == 'refs/heads/main' && - (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + github.event_name == 'workflow_dispatch' uses: actions/upload-artifact@v7 with: name: production-docs-${{ github.run_id }} @@ -71,7 +71,7 @@ jobs: name: Deploy documentation if: >- github.ref == 'refs/heads/main' && - (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + github.event_name == 'workflow_dispatch' needs: validate runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/privacy-guard.yml b/.github/workflows/privacy-guard.yml new file mode 100644 index 0000000..07a9c3b --- /dev/null +++ b/.github/workflows/privacy-guard.yml @@ -0,0 +1,51 @@ +name: Privacy Guard + +"on": + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: privacy-guard-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + name: Check Privacy Guard (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: + - "3.11" + - "3.14" + defaults: + run: + working-directory: projects/privacy-guard + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Set up uv and Python + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + version: "0.11.31" + python-version: ${{ matrix.python-version }} + + - name: Configure isolated uv paths + run: | + echo "UV_CACHE_DIR=$RUNNER_TEMP/privacy-guard-uv-cache" >> "$GITHUB_ENV" + echo "UV_PROJECT_ENVIRONMENT=$RUNNER_TEMP/privacy-guard-venv" >> "$GITHUB_ENV" + + - name: Install locked dependencies + run: uv sync --frozen + + - name: Run project checks + run: make check diff --git a/AGENTS.md b/AGENTS.md index 503b4a4..03de54f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,8 @@ read `docs/development/index.md`. ## Repository rules - Make the smallest change that satisfies the task and preserve unrelated work. +- Prefer explicit, clear names and language over concise but ambiguous + alternatives. Value concision when it does not reduce clarity. - Use `uv` for Python dependency management, environments, locking, builds, and command execution unless a project explicitly documents an exception. Treat `pyproject.toml` and the committed `uv.lock` as the dependency sources of truth. diff --git a/docs/development/index.md b/docs/development/index.md index 7871d11..0d29668 100644 --- a/docs/development/index.md +++ b/docs/development/index.md @@ -109,9 +109,12 @@ Dependabot pull requests validate with read-only credentials but do not publish previews on the production documentation origin. The `gh-pages` branch stores the composite production site and active previews; -GitHub Pages remains configured with **GitHub Actions** as its publishing source. -Pushes and manual workflow runs from `main` update the production site while -preserving active previews, then deploy the complete branch through the official -Pages artifact workflow. The first production deployment creates `gh-pages` -automatically. To roll back to a revision before preview support, leave the Pages -source set to **GitHub Actions** and rerun the restored documentation workflow. +GitHub Pages remains configured with **GitHub Actions** as its publishing +source. Pushes to `main` validate documentation without publishing it. When the +current documentation is ready for production, manually run the **Docs** +workflow from `main`; that run updates the production site while preserving +active previews, then deploys the complete branch through the official Pages +artifact workflow. The first production deployment creates `gh-pages` +automatically. To roll back to a revision before preview support, leave the +Pages source set to **GitHub Actions** and rerun the restored documentation +workflow. diff --git a/docs/documentation/index.md b/docs/documentation/index.md index 628d196..5579bdd 100644 --- a/docs/documentation/index.md +++ b/docs/documentation/index.md @@ -6,5 +6,9 @@ agent_markdown: true # Documentation -As Dev Notes introduce reusable software, this section will collect the technical -documentation and references needed to install, use, and reproduce that work. +Technical documentation and references for installing, using, and extending +OpenShell Research projects. + +- [Privacy Guard architecture](privacy-guard/architecture/index.md): component + boundaries, request flow, extension interfaces, concurrency, failures, and + limits diff --git a/docs/documentation/privacy-guard/architecture/configuration.md b/docs/documentation/privacy-guard/architecture/configuration.md new file mode 100644 index 0000000..2aadd71 --- /dev/null +++ b/docs/documentation/privacy-guard/architecture/configuration.md @@ -0,0 +1,149 @@ +--- +title: Configuration and text boundary +description: Runtime configuration ownership and the single-text processing contract. +agent_markdown: true +--- + +# Configuration and text boundary + +Privacy Guard processes the complete request body as one text string. Its +runtime configuration is supplied by OpenShell for each evaluation and +normalized into strict structured models before processing. + +## Bytes and text + +The service owns the transport boundary: + +- it validates the advertised 4 MiB request-body limit +- it allows an empty body without invoking an engine +- it decodes each non-empty body as strict UTF-8 +- it passes exactly one `str` to `RequestProcessor` +- detect and block return no body mutation +- replace UTF-8 encodes the final processed text + +Because detect and block return no mutation, OpenShell retains the exact +original bytes. Replace returns the final text even when it happens to equal +the input. + +Headers and media types do not change this behavior. Privacy Guard does not +parse JSON, select nested values, create document regions, or reconstruct +structured payloads. + +## Policy configuration + +The OpenShell policy owns: + +- ordered entity-processing stages +- each stage's exact engine configuration +- entity definitions and detection settings +- engine-specific replacement recipes +- the final detect, block, or replace action + +For example: + +```yaml +entity_processing: + stages: + - name: identifiers + config: + engine: regex + pattern_catalog: + entities: + - name: customer-id + rules: + - name: prefixed-eight-digit-id + pattern: '\bCUST-[0-9]{8}\b' + confidence: high + replacement: + strategy: template + template: "[{entity}]" +on_detection: + action: replace +``` + +`config.engine` is the Pydantic discriminator. `EngineRegistry.finalize()` +builds the complete policy model from the exact config type registered for each +engine. Engine-specific fields therefore validate and serialize without a +generic mapping layer. + +Deployment startup owns operational resources rather than privacy behavior: +installed engine implementations, approved model profiles, clients, endpoints, +credentials, and data-egress constraints. + +## Regex catalogs + +`RegexEngineConfig.pattern_catalog` accepts either a structured +`RegexPatternCatalog` or a relative `.yaml` or `.yml` path: + +```yaml +pattern_catalog: patterns.yaml +``` + +Paths resolve beneath Privacy Guard's working directory. Absolute paths, +traversal, and symlinks are rejected. Catalog files must be bounded UTF-8 YAML +without aliases, duplicate keys, or unsafe tags. + +File-backed and inline inputs normalize to the same `RegexPatternCatalog`. +The complete validated configuration contains the structured catalog rather +than its source path. File-backed catalogs are read and validated on each policy +validation. The normalized immutable catalog keys a bounded compiled-rule +cache, so equivalent rules do not need to be recompiled. A content change +produces a different validated configuration and causes the next evaluation to +prepare an active-policy replacement. + +Privacy Guard maintains the catalog schema and safety limits but does not ship +an authoritative pattern set. Repository catalogs are examples to copy and +adapt, not presets or runtime defaults. + +`ValidateConfig` proves that each Regex pattern has valid syntax and rejects +patterns that immediately match empty input. It cannot prove that every +context-dependent branch consumes text. If a boundary, lookaround, or +alternation produces a zero-width match only when triggering request text is +observed, evaluation rejects the policy as `config_invalid` rather than treating +the result as an internal engine failure. Lookarounds and boundaries remain +valid when the complete match consumes text. + +## Current transport constraint + +The copied OpenShell protocol carries policy configuration in a +per-evaluation `google.protobuf.Struct` limited to 64 KiB. An inline catalog is +bounded by that transport. A file-backed configuration carries only its bounded +relative path through the protocol and loads the deployment-mounted catalog in +the middleware process. + +The service rejects an encoded configuration above that limit before protobuf +conversion or policy validation. A policy may contain at most ten ordered +entity-processing stages. The service validates the complete bounded +configuration and compares it with the single active policy. Equal validated +configurations reuse the active processor; a different valid configuration pays +full engine and processor preparation before atomic replacement. Reuse avoids +repeated engine initialization but does not increase either limit. + +`Struct` carries every number as a double. At this transport boundary only, +Privacy Guard converts finite integral values in the safe integer range +`-(2^53 - 1)` through `2^53 - 1` to Python integers before strict policy +validation. Non-integral and out-of-range values remain floats, so integer +fields reject them. Direct Python registry validation remains strict and does +not perform this transport normalization. Custom-engine integer settings must +therefore remain within the safe range when supplied through OpenShell policy. + +A future self-contained transport for larger expanded catalogs requires an +upstream OpenShell contract for preparing configuration and referring to it +during evaluation. Privacy Guard must not create a private protocol fork. + +## Policy identity + +Privacy Guard compares the complete immutable validated configuration, +including every concrete engine field, nested replacement variant, and expanded +Regex catalog. Equal configurations reuse the active processor. A different +configuration is an update candidate and replaces the active policy only after +complete preparation succeeds. + +The current protocol carries neither an explicit update operation nor a policy +version. Privacy Guard therefore cannot distinguish an intentional user update +from any other changed per-evaluation configuration. OpenShell must send one +consistent policy stream to a process; interleaved old and new configurations +can repeatedly replace one another. A future protocol can make preparation and +activation explicit and let evaluations refer to a versioned policy. + +[Back to the architecture overview](index.md) diff --git a/docs/documentation/privacy-guard/architecture/entity-processing-engines.md b/docs/documentation/privacy-guard/architecture/entity-processing-engines.md new file mode 100644 index 0000000..d92d8c9 --- /dev/null +++ b/docs/documentation/privacy-guard/architecture/entity-processing-engines.md @@ -0,0 +1,334 @@ +--- +title: Entity-processing engines +description: Engine configuration, lifecycle, output contract, and extension rules. +agent_markdown: true +--- + +# Entity-processing engines + +An `EntityProcessingEngine` detects sensitive entities in one text string and +may replace them when requested. It has no access to the user-facing policy +action, request metadata, gRPC, or protobuf messages. + +Each configured stage owns one engine instance. The same instance may serve +concurrent requests, while separate stages may use the same implementation +with different configurations. + +## Engine interface + +An engine: + +1. declares a concrete configuration type and, when needed, a resource type + through generics +2. declares its non-empty set of `supported_strategies` +3. receives validated configuration and operator-owned resources through the + base constructor +4. optionally derives immutable reusable state in `_initialize()` +5. implements `_run(text, strategy, timeout)` +6. returns one `TextProcessingResult` + +The public `run()` method validates input, strategy support, the timeout, and +the complete output contract. Custom engines implement `_run()` and do not +override `run()` or define `__init__`. `_initialize()` is optional, and +`@override` is not required. The base constructor and public wrapper are final +lifecycle methods. Registration rejects direct or inherited overrides and +directs custom engines to `_initialize()` and `_run()` instead. + +## Configuration + +Every concrete config subclasses `EngineConfig` and declares exactly one +literal `engine` discriminator: + +```python +class AcmeEngineConfig(EngineConfig): + engine: Literal["acme-pii"] = "acme-pii" + replacement: AcmeReplacement | None = None +``` + +The object under `EntityProcessingStage.config` is this exact concrete model. +It is validated and serialized as a member of the registry-built Pydantic +discriminated union, then passed unchanged to the engine constructor. + +Custom integer fields remain strict for direct Python callers. OpenShell policy +uses protobuf `Struct`, whose numeric representation is a double, so the service +normalizes finite integral values only within the safe range `-(2^53 - 1)` +through `2^53 - 1`. Values outside that range and non-integral values remain +floats and fail strict integer fields. Nested models and lists follow the same +transport rule. + +`EngineConfig` is a nominal, strict base model. It does not prescribe a +`replacement` field or any other algorithm-specific setting. An engine that +needs a replacement recipe declares that field on its concrete config. Engines +with multiple replacement algorithms may define a nested discriminated union, +using fields such as `replacement.strategy`. An engine whose underlying tool +has intrinsic replacement behavior may support `REPLACE` without declaring a +replacement field at all. + +Configuration contains privacy behavior. An optional `EngineResources` object +contains operator-owned runtime dependencies such as initialized model clients, +SDK adapters, endpoints, credential providers, or approved model profiles. +Resources are registered by the operator and never serialized into policy. + +`EngineResources` is a nominal contract. A concrete resource bundle subclasses +it, is typed as the engine's second generic argument, and must: + +- contain operational dependencies rather than policy behavior +- retain no request text or mutable per-request state +- expose only dependencies that are safe for concurrent engine calls +- avoid relying on construction or mutation during request processing + +For example: + +```python +from dataclasses import dataclass + +from privacy_guard.engines import EngineResources + + +@dataclass(frozen=True) +class AcmeResources(EngineResources): + client: AcmeClient + + +class AcmeEngine(EntityProcessingEngine[AcmeEngineConfig, AcmeResources]): + ... +``` + +Resources are optional. A resource-free engine omits the second generic +argument and receives `None` from the base class: + +```python +class KeywordEngine(EntityProcessingEngine[KeywordEngineConfig]): + ... +``` + +## Invocation strategy + +```python +class EntityProcessingStrategy(StrEnum): + DETECT = "detect" + REPLACE = "replace" +``` + +An engine declares exactly the invocation strategies it supports. A +detection-only engine declares: + +```python +supported_strategies = frozenset({EntityProcessingStrategy.DETECT}) +``` + +A replacement-only engine declares: + +```python +supported_strategies = frozenset({EntityProcessingStrategy.REPLACE}) +``` + +An engine that exposes both operations includes both enum values. Supporting +`REPLACE` does not imply that the engine exposes `DETECT`, even when replacement +requires internal detection. Blocking does not appear here: the processor runs +engines with `DETECT` and applies the block disposition afterward. + +The registry calls `validate_run_config()` with the strategy derived from the +policy action. The base implementation verifies strategy support. An engine +may add technique-specific requirements through `_validate_run_config()`, such +as requiring a template only when `REPLACE` is requested. The engine receives +the strategy, never the user-facing `PolicyAction`. + +## Result contract + +`TextProcessingResult` contains: + +| Field | Meaning | +| --- | --- | +| `text` | Authoritative text returned by this stage | +| `detections` | Bounded tuple of all `EntityDetection` occurrences | + +Each `EntityDetection` contains: + +| Field | Meaning | +| --- | --- | +| `entity` | Bounded entity label | +| `start` | Inclusive Unicode code-point offset in the stage input | +| `end` | Exclusive Unicode code-point offset in the stage input | +| `confidence` | Optional `low`, `medium`, or `high` certainty | +| `metadata` | Optional bounded engine-specific attribution retained inside the processing boundary | + +A detection span is non-empty and must fall within the stage input. The public +wrapper independently enforces stage detection and output-size limits for every +returned result. Engines that produce detections lazily should use +`TextProcessingResult.from_detections()`; this optional convenience stops +consuming the iterable as soon as the stage limit is exceeded, but it is not +the enforcement boundary. + +For `DETECT`, output text must exactly equal input text. For `REPLACE`, a +successful return is the engine's authoritative completed result. Text may not +change without at least one detection. Engines must raise on native partial +failure instead of returning partial output. + +Privacy Guard preserves categorical confidence as supplied by the engine and +does not compare confidence across engines. + +## Custom engine example + +```python +import re +from typing import Literal + +from pydantic import Field + +from privacy_guard.engines import ( + EngineConfig, + EntityDetection, + EntityProcessingEngine, + EntityProcessingStrategy, + TextProcessingResult, +) +from privacy_guard.timeout import Timeout + + +class KeywordEngineConfig(EngineConfig): + engine: Literal["keyword"] = "keyword" + keyword: str = Field(min_length=1) + + +class KeywordEngine(EntityProcessingEngine[KeywordEngineConfig]): + supported_strategies = frozenset({EntityProcessingStrategy.DETECT}) + + def _run( + self, + text: str, + *, + strategy: EntityProcessingStrategy, + timeout: Timeout, + ) -> TextProcessingResult: + matches = re.finditer(re.escape(self.config.keyword), text) + return TextProcessingResult.from_detections( + text=text, + detections=( + EntityDetection( + entity="keyword", + start=match.start(), + end=match.end(), + ) + for match in matches + ), + ) +``` + +Register the implementation and, when required, its resources before finalizing +the registry: + +```python +registry = EngineRegistry(include_builtin_engines=True) +registry.register(KeywordEngine) +registry = registry.finalize() +``` + +The opt-in constructor argument registers the engines shipped by Privacy Guard +before application-specific engines. Omit it when the registry should contain +only explicitly registered implementations. + +Finalization freezes registration and constructs the exact policy config type, +JSON Schema, and engine discovery metadata. + +The complete example at +`projects/privacy-guard/examples/custom-engine/README.md` keeps the engine, +configuration, and registry factory in one Python file and runs that registry +through discovery, schema generation, serving, and an OpenShell policy. + +## Timeout and concurrency + +One `Timeout` is created for the processor run and passed through every stage. +The public engine wrapper checks it immediately before and after `_run()`, so +ordinary in-memory implementations do not repeat those checks. An engine must +not create a fresh per-stage duration. + +When a delegated API accepts a timeout, pass the remaining duration: + +```python +remaining = timeout.remaining_seconds() +``` + +When that API raises Python's `TimeoutError` on expiration, use the shared +context manager to translate it into Privacy Guard's domain error: + +```python +with timeout.enforce(): + result = client.process(text, timeout=timeout.remaining_seconds()) +``` + +Unique long-running loops may also call `timeout.raise_if_expired()`. +Operations that cannot be interrupted must be documented and bounded +independently. + +Engine configuration, derived state, and injected resources are shared across +concurrent requests. Keep request text, detections, and counters local to +`_run()`. Resources and engines must be concurrency-safe. + +## RegexEngine + +`RegexEngine` owns both regular-expression detection and deterministic template +replacement. Its `RegexPatternCatalog` contains structured entities and ordered +rules. Each rule combines one regex pattern with its optional diagnostic name, +confidence, and flags. Privacy Guard maintains the schema and limits but no +authoritative pattern set. + +Important properties: + +- rules compile once during configuration validation and initialization +- a non-capturing wrapper plus a private trailing named marker preserves + numeric backreferences and proves the configured match completed +- user-defined named groups and inline flags are rejected +- `ignore_case`, `multiline`, `dot_all`, and `ascii` are explicit fields +- detection retains overlapping matches within and across rules +- each backend search receives the shared remaining timeout +- rule names are optional; unnamed rules receive deterministic + diagnostic identities without changing serialized configuration +- catalogs may be supplied inline or through a bounded relative YAML path and + normalize to the same structured configuration +- replacement resolves overlaps by categorical confidence, span length, + offsets, entity, and rule identity +- templates allow literal text and `{entity}` only +- replacement size is projected before output allocation + +The third-party `regex` backend is used because it can interrupt an individual +search when the timeout expires. + +## Tool-specific custom engines + +Tool integrations belong in custom engines until they have a complete, +production-backed implementation. Privacy Guard does not ship placeholder +engine types or runtime protocols that merely resemble a third-party tool. + +The first NeMo Anonymizer integration will be a custom engine. Its configuration +and replacement types should preserve Anonymizer's native concepts, while the +engine itself owns all translation to and from the actual Anonymizer SDK. + +## State + +Cross-request entity memory is intentionally out of scope. The engine API has +no state argument, session identifier, persistent replacement map, or +placeholder storage interface. + +Future memory support requires separate decisions about tenant isolation, +retention, deletion, consistency, and policy ownership. + +## Testing an engine + +Test engines without gRPC: + +- valid and invalid exact configuration and resource types +- detection-only immutability +- replacement behavior and native partial failure +- Unicode offsets and invalid spans +- categorical confidence +- output and detection limits +- timeout propagation and expiration +- deterministic ordering where relevant +- concurrent calls over immutable initialized state +- content-safe errors that omit input and engine secrets + +Processor tests should cover only ordered multi-stage behavior, shared timeout, +policy disposition, aggregate limits, and stage-qualified findings. + +[Back to the architecture overview](index.md) diff --git a/docs/documentation/privacy-guard/architecture/index.md b/docs/documentation/privacy-guard/architecture/index.md new file mode 100644 index 0000000..2e8d932 --- /dev/null +++ b/docs/documentation/privacy-guard/architecture/index.md @@ -0,0 +1,171 @@ +--- +title: Privacy Guard architecture +description: System structure, component boundaries, and request flow for Privacy Guard. +agent_markdown: true +--- + +# Privacy Guard architecture + +Privacy Guard is OpenShell middleware that detects and optionally replaces +sensitive entities in provider-bound HTTP request text before OpenShell adds +provider credentials. + +This architecture is a clean break from the earlier implementation. A processor +run receives one UTF-8 text value and invokes an ordered pipeline of +entity-processing engines. Structured-body parsing and compatibility with the +previous extension and policy APIs are intentionally out of scope. + +Privacy Guard can allow the original body, allow a replacement body, or deny +the request. It never sends the provider request itself. + +## Where Privacy Guard runs + +```text +Sandbox process + | + | provider-bound HTTP request + v +OpenShell supervisor + | + | gRPC: SupervisorMiddleware + v +Privacy Guard + | + | allow original body, allow replacement body, or deny + v +OpenShell supervisor + | + | credentials added only after middleware allows + v +Provider +``` + +Privacy Guard implements the OpenShell-owned `SupervisorMiddleware` gRPC +service and advertises one HTTP-request binding in the pre-credentials phase. +The checked-in protocol and generated bindings are canonical copies from +OpenShell and must not be edited locally. + +## Component boundaries + +Source paths on these pages are relative to +`projects/privacy-guard/src/privacy_guard/`. + +- `service/` owns gRPC, protobuf conversion, UTF-8 decoding and encoding, + bounded worker scheduling, active-processor preparation and replacement, and + finding serialization. Outside generated `bindings/`, no other package + imports gRPC or generated bindings. +- `cli.py` owns command parsing, registry-factory loading, engine discovery, + configuration-schema output, logging options, and the adapter that starts + the programmatic server. Top-level `logging.py` provides the shared, + standard-library logging configuration used by the CLI and available to + programmatic deployments. +- `request_processor.py` runs configured stages over one text value, shares one + timeout across them, aggregates detections, and applies the user-facing + policy action. It does not import gRPC or implement an engine's algorithms. +- `engines/` defines the custom-engine contract, registers engine + implementations and operator-owned resources, builds the exact Pydantic + discriminated union, and contains the built-in Regex implementation. Each + engine owns its detection and replacement algorithms. +- `config.py` defines ordered stages and the required policy action. +- top-level `base.py` defines the package-wide strict immutable domain-model + base. +- `string_validators.py` defines shared string validators and field types. + +The OpenShell policy is the single source of privacy behavior: stage order, +each stage's exact engine configuration, entity definitions, replacement +recipes, and the final `detect`, `block`, or `replace` action. Deployment +configuration registers implementations and injects operational resources such +as model profiles, endpoints, clients, and credentials. + +## Request flow + +```text +HttpRequestEvaluation protobuf + | + v +PrivacyGuardMiddleware + validates phase and body size + validates and normalizes policy configuration + reuses the matching active RequestProcessor or prepares and activates + a replacement + decodes a non-empty body as strict UTF-8 + | + v +RequestProcessor.process(text) + derives DETECT or REPLACE engine strategy + creates one shared Timeout + | + v +stage 1 engine.run(current text) + | + v +stage 2 engine.run(stage 1 text) + | + ... + | + v +RequestProcessor + aggregates stage-qualified detections + applies detect, block, or replace + | + v +RequestProcessingResult + | + v +PrivacyGuardMiddleware + serializes bounded findings + encodes replacement text only for replace + | + v +HttpRequestResult protobuf +``` + +The processor passes only `EntityProcessingStrategy.DETECT` or +`EntityProcessingStrategy.REPLACE` to engines. Blocking is a request +disposition and never crosses the engine boundary. + +The processor is synchronous. The service runs it in a dedicated thread pool +so engine work does not block the gRPC event loop. + +## Core data types + +| Type | Meaning | +| --- | --- | +| `PrivacyGuardConfig` | Ordered entity-processing stages and the required action on detection | +| `EntityProcessingStage` | One configured engine invocation with an optional diagnostic name | +| `EngineConfig` | Nominal strict base for an engine's exact policy configuration | +| `EntityProcessingStrategy` | Per-run engine selection: detect or replace | +| `EntityDetection` | One occurrence with stage-input offsets and optional confidence | +| `TextProcessingResult` | One engine's authoritative output text and detections | +| `Timeout` | One monotonic deadline shared across all stages | +| `EntityDetectionSummary` | Bounded stage/entity/confidence aggregate for audit output | +| `RequestProcessingResult` | Allow or deny decision, detection summaries, and replacement text when requested | + +Pydantic domain models are strict, frozen, reject unknown fields, hide rejected +input from validation errors, and suppress sensitive fields from normal +representations. + +## Deliberate omissions + +- Cross-request entity memory is not part of v0. +- Engines do not receive transport metadata or the user-facing policy action. +- There is no parallel execution-plan model; preparation constructs a + `RequestProcessor` directly. +- There is no generic replacement field or replacement-strategy enum. Each + engine owns any replacement settings appropriate to its underlying + algorithm. +- Regex catalogs may be supplied inline or as bounded relative YAML paths + beneath Privacy Guard's working directory. + +## Read next + +- [Request lifecycle](request-lifecycle.md) explains configuration resolution, + ordered execution, actions, and output behavior. +- [Entity-processing engines](entity-processing-engines.md) defines the + extension contract and built-in engines. +- [Configuration and text boundary](configuration.md) covers the one-text + contract, configuration ownership, and current catalog limits. +- [Service boundary](service-boundary.md) covers gRPC adaptation, policy + activation, and concurrency. +- [Safety and limits](safety-and-limits.md) records failure behavior and + resource bounds. diff --git a/docs/documentation/privacy-guard/architecture/request-lifecycle.md b/docs/documentation/privacy-guard/architecture/request-lifecycle.md new file mode 100644 index 0000000..9a0cda9 --- /dev/null +++ b/docs/documentation/privacy-guard/architecture/request-lifecycle.md @@ -0,0 +1,178 @@ +--- +title: Request lifecycle +description: How Privacy Guard prepares policy, runs ordered engines, and applies one request action. +agent_markdown: true +--- + +# Request lifecycle + +`RequestProcessor` owns the complete protobuf-free flow for one text value. It +runs configured entity-processing stages in order, aggregates detections, and +applies the policy action. + +The gRPC service owns the surrounding bytes/text and configuration transport +boundaries. + +## Policy configuration + +The policy supplies: + +```yaml +entity_processing: + stages: + - name: optional-diagnostic-name + config: + engine: regex + pattern_catalog: + entities: + - name: email + rules: + - pattern: '...' + confidence: high + replacement: + strategy: template + template: "[{entity}]" +on_detection: + action: detect +``` + +`entity_processing` groups the ordered stages and owns their non-empty-list and +unique-diagnostic-name validation. V0 defines only `stages`. + +Each `EntityProcessingStage` contains: + +- optional `name`, used only as bounded diagnostic provenance +- required `config`, which is the exact concrete configuration model owned by + the selected engine + +`config.engine` is a Pydantic discriminator. After every implementation is +registered, `EngineRegistry.finalize()` constructs a real discriminated union +of their concrete config models. Engine-specific fields and nested replacement +variants therefore validate, serialize, and appear in JSON Schema without a +generic mapping or translation layer. + +When a stage name is omitted, Privacy Guard derives a deterministic one-based +label such as `regex[1]`. All resulting diagnostic names must be unique. + +## Configuration resolution and preparation + +For each evaluation under the current OpenShell protocol, the service: + +1. converts the protobuf `Struct` to a mapping +2. validates it through the finalized registry-backed Pydantic model +3. validates each concrete config against its registered implementation and + injected resources +4. validates the action/replacement compatibility +5. reuses the active `RequestProcessor` when its complete immutable validated + configuration is equal +6. otherwise serializes preparation, constructs every configured engine and a + candidate processor, and atomically activates the candidate only after full + success + +The first evaluation establishes the active policy and pays its preparation +cost. A failed validation or preparation leaves an existing active processor +unchanged and fails the triggering evaluation. `ValidateConfig` performs +validation without constructing engines or changing the active policy. + +There is no separate execution-plan abstraction. The validated stage order +already contains the necessary policy structure, and the active processor +privately retains the corresponding ordered engine instances. + +## Text input + +The service validates the pre-credentials phase and the request body byte +limit before processing. It still validates configuration and resolves the +active processor for an empty body, then immediately allows that body without +invoking an engine. + +A non-empty body must decode as strict UTF-8. The decoded `str` is the only +request input passed to `RequestProcessor`; headers, content type, request ID, +target, and protobuf messages do not cross that boundary. + +The processor validates the encoded UTF-8 byte bound before running the +pipeline. + +## Ordered stage execution + +The processor derives one invocation strategy for the whole pipeline: + +| Policy action | Engine strategy | +| --- | --- | +| `detect` | `DETECT` | +| `block` | `DETECT` | +| `replace` | `REPLACE` | + +`PolicyAction` is never passed to an engine. + +The processor then: + +1. creates one monotonic `Timeout` +2. calls each stage exactly once in policy order +3. passes the current text, invocation strategy, and shared timeout to + `engine.run()` +4. validates intermediate UTF-8 byte and detection limits +5. passes the returned text to the next stage +6. checks the same timeout after the final result + +In detect and block mode, the public engine contract requires returned text to +equal that stage's input. In replace mode, each later stage sees the preceding +stage's processed text. + +Detection offsets always refer to the input revision seen by the producing +stage. Privacy Guard does not reinterpret earlier offsets after a later stage +changes the text. + +If a stage times out, exceeds an execution limit, or fails, its partial text and +detections are discarded. No later stage runs. + +## Detection aggregation + +After all stages succeed, the processor aggregates detections by: + +```text +source stage + entity + confidence representation +``` + +It does not deduplicate across stages. Two stages may have inspected different +text revisions, and confidence values from different tools are not assumed to +be calibrated. + +The aggregate `EntityDetectionSummary` intentionally omits matched text, +surrounding text, offsets, patterns, and raw engine metadata. + +## Applying the policy action + +The processor owns the final disposition: + +| Action | No detections | One or more detections | +| --- | --- | --- | +| `detect` | Allow original body | Allow original body and report detection summaries | +| `block` | Allow original body | Deny with `privacy_guard_blocked` and report detection summaries | +| `replace` | Allow final processed text | Allow final processed text and report detection summaries | + +For `replace`, configuration validation requires every stage to advertise +replacement support and to include its valid engine-specific replacement +recipe. A recipe may remain configured but dormant when the action is changed +to detect or block. + +Replacement behavior belongs to each engine. For example, `RegexEngine` +selects deterministic non-overlapping matches and renders its constrained +template. A custom engine backed by another tool owns that tool's native +replacement operation. `RequestProcessor` does not reproduce either algorithm. + +## Output + +`RequestProcessor.process()` returns a `RequestProcessingResult`: + +- detect and block-without-detections return `ALLOW` without replacement text +- block-with-detections returns `DENY`, detection summaries, and + `privacy_guard_blocked` +- replace returns `ALLOW`, the final text, and detection summaries +- timeout or execution-limit exhaustion returns `DENY` with + `privacy_guard_limit_exceeded` and no partial summaries or replacement + +The service leaves the original request bytes untouched for detect and block. +For replace it UTF-8 encodes the final text and sets `has_body=true`, including +when the final text happens to equal the input. + +[Back to the architecture overview](index.md) diff --git a/docs/documentation/privacy-guard/architecture/safety-and-limits.md b/docs/documentation/privacy-guard/architecture/safety-and-limits.md new file mode 100644 index 0000000..3b22fec --- /dev/null +++ b/docs/documentation/privacy-guard/architecture/safety-and-limits.md @@ -0,0 +1,286 @@ +--- +title: Safety and limits +description: Validation ownership, bounded entity processing, failure behavior, and logging rules. +agent_markdown: true +--- + +# Safety and limits + +Privacy Guard bounds request text, engine output, detections, regex execution, +transport output, and concurrency. Limits are part of the architecture, not +optional tuning defaults. + +Package-wide values live in `constants.py`. + +## Validation ownership + +Each layer validates facts it can establish: + +| Layer | Validates | +| --- | --- | +| Policy and engine config models | Strict types, exact fields, discriminators, local field rules, catalog bounds, and regex validity | +| `EngineRegistry` | Registration contracts, exact config and resource types, pure resource-backed config checks, and action/replacement compatibility | +| `EntityProcessingEngine.run()` | Text and invocation types, supported strategy, timeout, result model, spans, detection-only immutability, mutation attribution, and stage output bounds | +| Concrete engine | Algorithm-specific execution, replacement completeness, backend timeout propagation, and native failure normalization | +| `RequestProcessor` | Request text bounds, shared timeout, intermediate output, request-wide detection count, aggregation, and policy disposition | +| Service | Phase, transport body size, strict UTF-8, protobuf conversion, replacement encoding, finding representation, and response limits | + +Keep request-wide policy decisions in the processor and algorithm-specific +behavior in the concrete engine. The service adapts transport but does not +reimplement either. + +## Text, processing, and transport limits + +| Limit | Constant | Value | Owner | +| --- | --- | ---: | --- | +| Incoming request body | `MAX_BODY_BYTES` | 4 MiB | Service | +| Input or intermediate UTF-8 bytes | `MAX_BODY_BYTES` | 4 MiB | Processor | +| Engine output UTF-8 bytes | `MAX_BODY_BYTES` | 4 MiB | Engine wrapper | +| Encoded replacement body | `MAX_BODY_BYTES` | 4 MiB | Service | +| gRPC receive message | `MAX_RECEIVE_MESSAGE_BYTES` | 5 MiB | gRPC server | +| Detections returned by one stage | `MAX_DETECTIONS_PER_STAGE` | 256 | Engine wrapper | +| Detections across one request | `MAX_DETECTIONS_PER_REQUEST` | 4,096 | Processor | +| Default shared timeout | `DEFAULT_TIMEOUT_SECONDS` | 1 second | Server and processor | +| Maximum configured timeout | `MAX_TIMEOUT_SECONDS` | 30 seconds | Server, processor, and `Timeout` | +| Active processor workers | `MAX_CONCURRENT_PROCESSING` | 4 | Service | +| Concurrent gRPC calls | `MAX_CONCURRENT_RPCS` | 16 | gRPC server | + +The gRPC receive allowance is 1 MiB larger than the request body limit for the +protobuf envelope. The service still enforces the advertised 4 MiB body bound. + +One monotonic `Timeout` is shared by all stages and final result validation. +`RegexEngine` passes the remaining duration into every backend search. +Third-party calls that accept a timeout should receive the same remaining +duration; calls that cannot be interrupted continue to occupy a service worker +until they exit. + +Operators configure the internal bound with `--timeout-seconds` or the +programmatic `PrivacyGuardServer(timeout_seconds=...)` argument. OpenShell's +outer middleware timeout must include additional headroom for worker queueing +and configuration or engine preparation beyond this internal bound. + +## Diagnostic and result limits + +| Limit | Constant | Value | +| --- | --- | ---: | +| Bounded diagnostic string | `MAX_DIAGNOSTIC_TEXT_BYTES` | 1,024 UTF-8 bytes | +| Metadata entries per detection | `MAX_FINDING_METADATA_ENTRIES` | 32 | +| Aggregated protobuf finding groups | `MAX_PROTO_FINDING_GROUPS` | 32 | +| Encoded size per protobuf finding | `MAX_PROTO_FINDING_BYTES` | 4 KiB | + +The diagnostic-string bound applies to stage names, entity names, metadata +keys and values, model-profile names, and other audit-safe identifiers built +from the shared domain field type. These values must be non-empty printable +Unicode without control or bidirectional formatting characters. Untrusted +request IDs use the same content and size rules for logging; an invalid request +ID is represented by a constant placeholder and does not change the evaluation +result. The human-readable log message quotes request IDs and escapes ASCII +spaces so their text cannot introduce another key/value token; structured log +records retain the exact validated ID. Regex entity and supplied rule names +have a stricter ASCII grammar and limit described below. + +The processor aggregates occurrences before the service applies protobuf +limits. If a safe result cannot be represented, the service returns a limit +deny with no partial findings. + +## Regex and catalog limits + +| Limit | Constant | Value | +| --- | --- | ---: | +| Regex entity or supplied rule name | `MAX_REGEX_NAME_BYTES` | 128 ASCII bytes | +| Entities per catalog | `MAX_REGEX_ENTITIES_PER_CATALOG` | 2,000 | +| Rules per catalog | `MAX_REGEX_RULES_PER_CATALOG` | 10,000 | +| Pattern string | `MAX_REGEX_PATTERN_BYTES` | 16 KiB | + +Entity and rule names use +`[A-Za-z_][A-Za-z0-9_-]*`. Rule names are optional; their deterministic +derived diagnostic identities are not serialized back into configuration. + +The per-evaluation config `Struct` is limited to 64 KiB. A catalog may satisfy +Privacy Guard's engine limits yet remain too large for the current upstream +protocol. Internal caching does not raise that transport ceiling. + +## Regex execution safety + +`RegexEngine` validates and compiles the complete catalog before accepting +configuration. It: + +- rejects invalid and empty pattern strings +- rejects patterns that match empty input +- rejects user-defined named groups and inline flags +- verifies its private trailing marker on every match +- treats a context-dependent zero-width match as an atomic configuration + failure +- evaluates patterns independently to retain overlaps +- uses the timeout-capable third-party `regex` backend +- caps detections per stage +- projects exact UTF-8 replacement size before rendering + +No timeout, limit, or pattern failure returns partial stage detections or +mutated text. + +## When a limit is exceeded + +The processor returns a successful deny with +`privacy_guard_limit_exceeded` and no partial findings or replacement when: + +- the shared timeout expires +- an engine exceeds its detection or output limit +- intermediate text exceeds the UTF-8 byte limit +- aggregate request detections exceed the limit + +The service returns the same bounded deny when: + +- findings exceed the protobuf group or encoded-size limit +- replacement text cannot be encoded within the body limit +- a deny reason code is not safely representable + +The processor emits a content-safe `timeout` or `resource` limit kind, and the +service adapter emits `resource` for representation limits. Neither log includes +request content or collaborator error text. The deny reason directs users to +check that log, then reduce the request or replacement size, simplify the +configured stages and rules, or increase `--timeout-seconds` or +`PrivacyGuardServer(timeout_seconds=...)` up to 30 seconds. When increasing the +processing timeout, users must give OpenShell's middleware timeout additional +headroom for worker queueing and configuration or engine preparation. These +options reduce bounded output pressure or provide appropriate time without +weakening the fail-closed behavior. + +Malformed input and engine contract or execution failures instead abort the RPC +with a cataloged error. OpenShell then applies the middleware registration's +failure behavior. + +The distinction is deliberate: + +- an exhausted declared processing bound is a closed, successful deny +- invalid user input is an `INVALID_ARGUMENT` RPC failure +- an engine or middleware defect is an `INTERNAL` RPC failure + +## Error catalog + +Production failures use `PrivacyGuardError` and a stable `ErrorCode`. Each +error spec defines: + +- invalid-input or internal classification +- responsible component +- failed operation +- content-safe summary +- remediation hint + +Successful policy and safety-limit denials use stable reason codes plus +content-safe recovery guidance; they do not add domain fields solely for +diagnostic logging. CLI validation failures name the rejected option and state +the accepted form or next action. Cataloged server-startup failures print one +content-safe message and exit nonzero rather than exposing a traceback. +Internal engine and third-party failures remain concise because the owning +boundary translates them before they reach users. + +Caught extension and third-party exceptions are translated at their trust +boundary. Raw exception messages and chains are not exposed. Configuration +errors do not include rejected pattern strings, paths, credentials, provider +endpoints, or request text. + +Use a new catalog entry for a genuinely new externally observable failure. Do +not return raw Pydantic, regex-backend, engine, or protobuf exceptions. + +## Logging + +Default operational logs include: + +- request ID +- evaluation duration +- allow or deny action +- aggregate finding count +- stable error code +- stage source and invocation strategy at debug level + +They do not include: + +- request or replacement text +- matched or surrounding text +- offsets +- patterns or catalog contents +- headers, targets, or credentials +- model endpoints +- arbitrary exception text + +Sensitive domain fields use `repr=False` to reduce accidental exposure. This +does not replace safe exception handling. + +`--debug` enables content-safe processing diagnostics. +`--debug-log-content` deliberately logs complete input and processed text and +emits a warning; use it only in a controlled development environment. + +## Extension requirements + +Custom engines should: + +- use the base constructor and public `run()` wrapper +- return the exact `TextProcessingResult` contract +- return stable, declared entity identifiers rather than values derived from + request text +- keep request data local to `_run()` +- keep initialized state immutable and make resources concurrency-safe +- pass the shared remaining timeout to delegated APIs when they support it +- fail the stage on native partial failure +- use `TextProcessingResult.from_detections()` to bound lazy detection streams +- add manual bounds only when a low-level collaborator allocates proportional + output before Privacy Guard can consume it +- translate expected collaborator failures to the content-safe engine + exception hierarchy +- avoid logging input or caught exception text + +Privacy Guard keeps framework-controlled fields and identifiers that pass its +shared validation content-safe for findings and operational logs. +Operator-installed Python engine code is trusted deployment code: it can access +request text and must honor the declared identifier and logging contract. The +framework cannot make arbitrary trusted Python code non-exfiltrating, so +operators must review and install custom engines with the same care as other +code in the middleware process. + +Do not add retries, fallback providers, persistent request artifacts, or extra +validation without a concrete failure mode and a clear owning layer. + +## State and retention + +Cross-request entity memory is not implemented. Privacy Guard retains one active +validated policy and its immutable configured engine state, but never request +text, detections, or replacement mappings. The active processor is deliberate +process state, not an LRU entry: it remains until successful replacement or +shutdown. + +During an update, the fully prepared candidate may coexist with the active +processor. Evaluations that already captured the prior processor may keep it +alive until they finish. A failed candidate leaves active state unchanged. + +Regex's supporting compiled-rule cache remains bounded: + +| Cache | Weight budget | Entry cap | Entry weight | +| --- | ---: | ---: | --- | +| Compiled Regex rules | 32 MiB | 128 | canonical catalog bytes plus 4 KiB per rule | + +The compiled-rule allowance accounts for retained backend state that is much +larger than short pattern text. One entry that exceeds the budget remains valid +and usable for the current operation but is not retained there. This does not +invalidate an active or candidate processor holding those rules. The active +policy is not rejected by cache admission; policy and schema limits bound its +shape, while custom-engine prepared memory remains an operator responsibility. +Regex cache skip and eviction records contain only the cache name and weights, +plus eviction counts. + +## Changing a limit + +A limit change may affect policy schema, processor behavior, engine contracts, +protobuf serialization, tests, examples, and the OpenShell middleware +registration. + +Before changing a limit: + +1. identify every layer that enforces or advertises it +2. update exact-boundary tests +3. check the failure result immediately above the boundary +4. run the complete project validation +5. benchmark changes that affect regex compilation or request processing +6. coordinate upstream when the copied OpenShell protocol is involved + +[Back to the architecture overview](index.md) diff --git a/docs/documentation/privacy-guard/architecture/service-boundary.md b/docs/documentation/privacy-guard/architecture/service-boundary.md new file mode 100644 index 0000000..01ca64f --- /dev/null +++ b/docs/documentation/privacy-guard/architecture/service-boundary.md @@ -0,0 +1,346 @@ +--- +title: Service boundary +description: gRPC adaptation, policy activation, worker scheduling, and server lifecycle. +agent_markdown: true +--- + +# Service boundary + +Outside the generated `bindings/` package, `service/` is the only layer that +imports gRPC or generated protobuf bindings. It translates between OpenShell's +transport contract and Privacy Guard's one-text domain contract. + +The `.proto` file and generated bindings checked into this project are copied +from OpenShell. Protocol changes must land upstream and be adopted from a new +canonical copy; Privacy Guard must not create a private fork. + +## gRPC methods + +Privacy Guard implements the three methods currently defined by +`SupervisorMiddleware`: + +| RPC | Behavior | +| --- | --- | +| `Describe` | Advertises service identity, one pre-credentials HTTP binding, and the 4 MiB body limit | +| `ValidateConfig` | Validates supplied policy configuration and registered resources | +| `EvaluateHttpRequest` | Validates transport input, resolves the active processor, processes text, and returns a decision | + +`Describe` advertises only +`SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS`. Evaluations at any other phase +are invalid input. + +The current manifest message has no field for the finalized policy schema or +engine discovery metadata. Those are available through the local +`privacy-guard configuration-schema` and `privacy-guard engines` commands. + +## Configuration lifecycle + +The current protocol carries a complete `google.protobuf.Struct` on both +validation and every request evaluation. + +`ValidateConfig`: + +1. rejects an encoded `Struct` larger than 64 KiB before conversion +2. converts the `Struct` to a mapping +3. validates the registry-built discriminated policy model +4. validates each exact engine config against registered resources +5. validates replacement support for a replace action +6. returns `valid=true`, or a content-safe reason + +It does not construct engines, change the active policy, contact model providers, +download resources, or write artifacts. Validation runs in the bounded worker +pool so catalog parsing and engine-specific checks do not block the async gRPC +event loop. + +During evaluation, the service validates and normalizes the same config. It +retains one active pair: the complete immutable validated configuration and its +configured `RequestProcessor`. Equal config reuses the processor. When no +processor is active or config differs, one serialized update path constructs +engines from the exact stage configs and operator-injected resources, constructs +a candidate processor, and atomically replaces the active pair only after +preparation succeeds. Failure preserves the prior active pair and fails the +triggering evaluation. + +Validation, resolution or preparation, strict UTF-8 decoding, and processing all +run in the bounded worker pool. Validation still occurs for every evaluation. +Equivalent normalized Regex catalogs may reuse an entry in the independent +compiled-rule cache during validation and engine construction. + +Regex's compiled-rule LRU retains at most 128 catalogs and 32 MiB. Each entry +weighs its canonical catalog bytes plus 4 KiB per rule. Evicting an entry only +removes the cache's reference. A processor that already uses those rules remains +valid. + +An otherwise valid compiled-rule entry larger than the byte budget is built and +used for the current operation but is not retained in that cache. The active +processor itself has no LRU admission budget: it is deliberate process state +retained until replacement or shutdown. Regex cache eviction or a skipped entry +never invalidates it. Process restart discards the active processor, and the +first later evaluation reconstructs it from supplied configuration. + +Each active processor receives the server's operational processing timeout. +The default is 1 second shared across every configured stage. Operators may set +up to 30 seconds with `privacy-guard serve --timeout-seconds`; programmatic +applications pass the same `timeout_seconds` argument to `PrivacyGuardServer`. +OpenShell's outer middleware `timeout` must include the processing timeout plus +additional headroom for worker queueing, repeated configuration validation, +and occasional candidate preparation so the supervisor does not end the RPC +first. + +## Incoming requests + +For each evaluation, the service: + +1. validates the pre-credentials phase +2. validates the encoded context (4 KiB), configuration (64 KiB), target + (32 KiB), headers (128 entries and 64 KiB), and body (4 MiB) limits +3. acquires one bounded worker slot +4. converts transport-safe integral numbers, validates the maximum ten stages, + and resolves or updates the active policy in that worker +5. allows an empty body without invoking an engine +6. decodes a non-empty body as strict UTF-8 +7. calls `RequestProcessor.process(text)` in the same worker + +Request context, target, headers, middleware name, and protobuf values remain at +the service boundary. The request ID is used only for content-safe operational +logging. + +## Outgoing results + +The processor returns `RequestProcessingResult`. The service maps it to +`HttpRequestResult`: + +| Domain result | Protobuf result | +| --- | --- | +| Detect allow | `DECISION_ALLOW`, `has_body=false` | +| Block allow with no detections | `DECISION_ALLOW`, `has_body=false` | +| Replace allow | `DECISION_ALLOW`, `has_body=true`, final UTF-8 body | +| Policy block | `DECISION_DENY`, `privacy_guard_blocked`, no body | +| Limit deny | `DECISION_DENY`, `privacy_guard_limit_exceeded`, no body or partial findings | + +The service checks the encoded replacement size again before serialization. + +### Findings + +The processor has already aggregated occurrences by source stage, entity, and +confidence. For each `EntityDetectionSummary`, the service emits: + +- `type`: `detected_entity` +- `label`: `entity (source-stage)` +- `confidence`: the categorical value or empty when absent +- `count`: aggregate occurrence count + +The current OpenShell `Finding` message has no dedicated source field. +Stage provenance is therefore secondary text in the bounded label while the +entity remains primary. Matched content, offsets, patterns, and raw engine +metadata never cross the protobuf boundary. + +## Concurrency model + +The gRPC server accepts at most 16 concurrent RPCs. Synchronous processing uses +a dedicated executor and semaphore with four active slots: + +```text +gRPC event loop + | + | acquire processing slot + v +4-slot semaphore + | + v +4-thread executor + | + v +configuration validation +and active-policy resolution + | + v +RequestProcessor.process + | + v +ordered engine pipeline +``` + +This keeps synchronous configuration and engine work off the async event loop +and bounds the number of active operations. + +The active processor, engine instances, and injected resources may be used by +multiple worker threads. They must retain no mutable per-request state and must +be safe for concurrent access. Policy preparation is serialized. A candidate is +fully prepared before the active reference is swapped. An evaluation that +already captured the old processor may finish with it while later evaluations +use the replacement; this is an atomic reference cutover, not a draining +cutover. + +## Cancellation + +Cancelling an async RPC cannot stop Python code already running in its worker +thread. The service shields the worker bridge and releases the semaphore slot +only after that worker actually finishes. + +Cancellation therefore cannot create more than four active synchronous +operations. +An engine should pass the remaining shared timeout to any delegated API that +supports bounded execution. A non-preemptible call continues to occupy its slot +until it exits. + +During shutdown, the server stops gRPC and waits for active executor work. + +## Error mapping + +`PrivacyGuardError` classifies each cataloged failure as invalid input or +internal failure: + +| Error kind | gRPC status | +| --- | --- | +| `invalid_input` | `INVALID_ARGUMENT` | +| `internal` | `INTERNAL` | + +This mapping applies to evaluation. `ValidateConfig` instead returns +`valid=false` with a content-safe reason. + +Unexpected failures become `unexpected_service_failure`. The service does not +return caught collaborator messages or exception chains. + +A gRPC failure is distinct from a successful policy deny. OpenShell applies the +middleware registration's failure behavior when an RPC fails. A policy deny is +a successful RPC result that explicitly stops the request. + +## Programmatic server lifecycle + +`PrivacyGuardServer` is the high-level programmatic API. It owns: + +```text +EngineRegistry + -> PrivacyGuardMiddleware + -> gRPC server +``` + +The built-in registry includes `RegexEngine`. Operators register custom engines +and resource-backed tool integrations before registry finalization, then pass +that registry to `PrivacyGuardServer`. + +The registry is an explicit application-scoped dependency, not a global +singleton. `PrivacyGuardServer` and `PrivacyGuardMiddleware` reject unfinalized +registries. A deployment creates and finalizes one registry during startup; +the middleware constructs configured stage engines and the active processor +from that registry. +Different middleware applications in the same process may intentionally use +different engine inventories or runtime resources. + +Synchronous applications use: + +```python +from privacy_guard.engines.registry import create_builtin_registry +from privacy_guard.service import PrivacyGuardServer + +server = PrivacyGuardServer( + create_builtin_registry(), + timeout_seconds=5, +) +server.serve_sync("127.0.0.1:50051") +``` + +Async applications call `await server.serve_async(address)` instead. The +explicit suffixes make the blocking behavior visible at each call site. Both +entry points use the same server instance and lifecycle. + +The listen address uses `host:port` or bracketed IPv6 `[address]:port` form, +with an explicit port from 1 through 65535. Privacy Guard rejects zero and +out-of-range numeric ports before calling gRPC, then verifies that gRPC bound +the requested port before starting the service. + +The server: + +1. creates an unstarted async gRPC server with receive and concurrency limits +2. binds the configured address +3. starts and waits for termination +4. stops gRPC +5. closes the middleware executor + +A bind or asynchronous gRPC startup `RuntimeError` becomes the stable +`server_bind_failed` error. Its catalog operation is the broader `server.start` +phase so the rendered failure does not mislabel a post-bind startup error. + +The server module has no command framework, module-import-string, discovery, +schema-rendering, or command-logging responsibilities. + +## Command-line application + +`privacy_guard.cli` owns the Typer application. The `--registry-factory` option +identifies a Python function in `module:function` form. At startup, the CLI +imports the module, calls the function once, and uses the returned finalized +`EngineRegistry` for the selected command: + +```bash +privacy-guard --registry-factory my_engines:create_registry engines +privacy-guard --registry-factory my_engines:create_registry configuration-schema +privacy-guard --registry-factory my_engines:create_registry serve +``` + +This is a deployment setting, not part of an OpenShell policy. The person +starting Privacy Guard chooses the function, and importing its module executes +Python code, so it must refer to code that the deployer controls. A policy +cannot select a factory or cause Privacy Guard to import a module. + +Applications that start `PrivacyGuardServer` from Python do not need this CLI +option. They create the registry normally and pass it to the server: + +```python +registry = create_registry() +PrivacyGuardServer(registry, timeout_seconds=5).serve_sync() +``` + +The CLI exposes: + +```bash +privacy-guard engines +privacy-guard configuration-schema +privacy-guard serve --listen 127.0.0.1:50051 --timeout-seconds 5 +``` + +Entity behavior comes from policy configuration, not server startup flags. +Engine implementations and operational resources come from the selected +application registry. The processing timeout is an operational server bound, +not policy-controlled behavior. + +## Upstream protocol work + +The intended large-catalog and discovery experience requires coordinated +OpenShell changes for: + +- explicit preparation and activation that accepts expanded configuration + larger than the current 64 KiB evaluation limit +- versioned policy references on evaluations +- finalized policy schema and engine discovery in the manifest +- a dedicated finding source field + +These are protocol evolution items, not local implementation hooks. Until they +land upstream, Privacy Guard continues to validate the per-evaluation config, +treat a changed valid config as an active-policy update, expose discovery +through the CLI, and render stage provenance inside the finding label. + +The protocol cannot distinguish an intentional update from any other changed +configuration. Each process must receive one consistent policy stream. +Interleaved old and new configurations can repeatedly switch the active policy. + +## Testing the boundary + +Service tests should cover: + +- protobuf/domain translation +- manifest fields supported by the current protocol +- pure policy validation +- phase, body-size, and UTF-8 validation +- first activation, equal-config reuse, serialized replacement, failed-update + preservation, and concurrent cutover +- detect, replacement, block, and limit serialization +- finding encoding and limits +- gRPC status mapping +- worker-slot behavior under cancellation +- startup, bind failure, and shutdown + +Engine algorithms and ordered policy semantics belong in engine and processor +tests unless transport translation is essential to the case. + +[Back to the architecture overview](index.md) diff --git a/projects/README.md b/projects/README.md index 2537ea8..d9d643c 100644 --- a/projects/README.md +++ b/projects/README.md @@ -8,6 +8,8 @@ Current projects: - `openshell-middleware-kit`: `omkit` CLI that creates and updates version-matched Python and Rust OpenShell supervisor middleware projects. +- `privacy-guard`: OpenShell supervisor middleware for inspecting and enforcing + policy on provider-bound requests before credentials are attached. - `python-project-template`: Minimal, production-ready Python project scaffold managed with uv. - `reachy-mini-openshell`: Reachy Mini conversation demo for OpenShell. diff --git a/projects/privacy-guard/.openshell-middleware-manifest.json b/projects/privacy-guard/.openshell-middleware-manifest.json new file mode 100644 index 0000000..d596981 --- /dev/null +++ b/projects/privacy-guard/.openshell-middleware-manifest.json @@ -0,0 +1,13 @@ +{ + "openshell_version": "v0.0.90", + "proto_source": "https://raw.githubusercontent.com/NVIDIA/OpenShell/v0.0.90/proto/supervisor_middleware.proto", + "proto_sha256": "e9d5a992ff5b50a33e9625176aaf6df8496d6774aa2ef3afe5cae7bc83c01105", + "languages": [ + "python" + ], + "python_package": "privacy_guard", + "generator": { + "name": "openshell-middleware-kit", + "version": "0.1.0" + } +} diff --git a/projects/privacy-guard/AGENTS.md b/projects/privacy-guard/AGENTS.md new file mode 100644 index 0000000..c48302a --- /dev/null +++ b/projects/privacy-guard/AGENTS.md @@ -0,0 +1,130 @@ +# Privacy Guard + +Privacy Guard is OpenShell middleware that runs an ordered pipeline of +entity-processing engines over one UTF-8 request body and applies a user-facing +detect, block, or replace action. + +## Development commands + +Run commands from `projects/privacy-guard/`. + +- List targets: `make help` +- Run all checks: `make check` +- Check Python 3.11: `make check-py311` +- Run focused tests: `make test PYTEST_ARGS=tests/test_request_processor.py` + +Run focused tests while working and `make check` before handoff. + +## Engineering approach + +- Backwards compatibility is explicitly not a concern for the v0 redesign. Do + not restore legacy behavior, schemas, imports, names, tests, or examples. +- Add defensive handling only for a concrete failure mode at the layer that + owns it. Avoid speculative guards, duplicate validation, broad catches, + retries, and fallbacks. +- Prefer explicit, domain-specific names. Avoid generic intermediate + abstractions that do not own behavior. +- Keep public declarations before private helper types, functions, methods, and + constants when dependency ordering permits. Put private implementation + details at the bottom of their module or class. + +## Project map + +- `src/privacy_guard/engines/`: engine contract, registry, and built-in implementations +- `src/privacy_guard/config.py`: policy action and ordered stage configuration +- `src/privacy_guard/request_processor.py`: stage execution and policy disposition +- `src/privacy_guard/cli.py`: command parsing, discovery, gateway setup, + configuration-schema output, and server adapter +- `src/privacy_guard/gateway_config.py`: safe OpenShell gateway TOML + registration updates +- `src/privacy_guard/logging.py`: package-scoped standard-library logging configuration +- `src/privacy_guard/base.py`: package-wide strict immutable domain-model base +- `src/privacy_guard/string_validators.py`: shared string validators and field types +- `src/privacy_guard/service/`: gRPC lifecycle and protobuf adapter +- `src/privacy_guard/bindings/`: generated protobuf files; never hand-edit +- [`docs/architecture/`](docs/architecture/index.md): symlink to the canonical + site sources under `../../docs/documentation/privacy-guard/architecture/` +- `tests/`: tests that mirror source boundaries +- `examples/`: copyable policy-authoring examples + +Before changing `request_processor.py`, `engines/`, or `service/`, read the +architecture overview and matching topic page. Architecture changes follow +[`docs/development/index.md`](../../docs/development/index.md) and require its +checks. + +## Design boundaries + +- One processor call receives one text string. Do not reintroduce request-body + codecs, format handlers, document regions, or JSON traversal. +- An `EntityProcessingEngine` receives engine configuration, a processing + strategy, and a shared `Timeout`. It never receives or infers the policy + action. +- `RequestProcessor` runs configured stages in order and owns detect, block, or + replace disposition. +- Engine configuration lives inside the OpenShell policy as the exact Pydantic + discriminated-union member registered for that engine. +- Deployment startup owns only installed engine implementations and operational + resources such as clients, endpoints, models, and credentials. +- Engine instances and injected resources serve concurrent requests. Do not + retain request content or mutable per-request state. +- Outside generated `bindings/`, only `service/` may import gRPC or generated + bindings. +- The copied OpenShell `.proto` and generated bindings must be updated only + through `openshell-middleware-kit`; never edit them manually. + +## Extension pattern + +Define a concrete `EngineConfig` and implement `_run`. Custom engines do not +define `__init__`; use optional `_initialize` for derived immutable state. +`@override` is not required. + +Resource-free engines omit the second generic argument. Resource-backed engines +declare an `EngineResources` subclass as that argument; the bundle contains +only operator-owned, concurrency-safe runtime dependencies and no policy +behavior or per-request state. + +```python +from typing import Literal + +from privacy_guard.engines import ( + EngineConfig, + EntityProcessingEngine, + EntityProcessingStrategy, + TextProcessingResult, +) +from privacy_guard.timeout import Timeout + + +class KeywordConfig(EngineConfig): + engine: Literal["keyword"] = "keyword" + keyword: str + + +class KeywordEngine(EntityProcessingEngine[KeywordConfig]): + supported_strategies = frozenset({EntityProcessingStrategy.DETECT}) + + def _run( + self, + text: str, + *, + strategy: EntityProcessingStrategy, + timeout: Timeout, + ) -> TextProcessingResult: + return TextProcessingResult(text=text, detections=()) +``` + +The public `run` method validates input, strategy, timeout, and extension +output. Custom code checks the timeout itself only for delegated calls or +unique long-running loops. Register every engine before finalizing the registry +so policy serialization retains its exact config type. Custom deployments +expose a `module:factory` callable that returns the application-scoped finalized +registry and pass it to the CLI with `--registry-factory`. + +## Change limits + +- Add or update tests at the layer that owns the behavior. +- Ask before adding dependencies or changing the OpenShell protobuf contract, + stable error codes, protocol limits, or fail-closed defaults. +- Do not remove or weaken relevant tests merely to pass checks. +- Do not add casts, explicit `Any`, blanket ignores, or broad type suppressions + to handwritten code. `tests/test_typing_policy.py` enforces this. diff --git a/projects/privacy-guard/LICENSE b/projects/privacy-guard/LICENSE new file mode 100644 index 0000000..10a6d3d --- /dev/null +++ b/projects/privacy-guard/LICENSE @@ -0,0 +1,203 @@ +Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 NVIDIA CORPORATION & AFFILIATES. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/projects/privacy-guard/Makefile b/projects/privacy-guard/Makefile new file mode 100644 index 0000000..95223f1 --- /dev/null +++ b/projects/privacy-guard/Makefile @@ -0,0 +1,63 @@ +.DEFAULT_GOAL := help + +UV ?= uv +UV_RUN := $(UV) run --frozen +PYTEST_ARGS ?= +.PHONY: \ + help sync lock lock-check \ + test \ + format format-check lint lint-fix typecheck fix \ + import-check build check check-py311 \ + clean + +help: ## Show available targets and configurable variables. + @awk 'BEGIN {FS = ":.*## "}; /^[a-zA-Z0-9_.-]+:.*## / {printf " %-28s %s\n", $$1, $$2}' $(MAKEFILE_LIST) + @printf "\nVariables:\n" + @printf " PYTEST_ARGS=... Extra pytest paths or flags\n" + +sync: ## Install locked runtime and development dependencies. + $(UV) sync --frozen + +lock: ## Refresh uv.lock from pyproject.toml. + $(UV) lock + +lock-check: ## Verify uv.lock matches pyproject.toml. + $(UV) lock --check + +test: ## Run tests; pass paths or flags with PYTEST_ARGS. + $(UV_RUN) pytest -q $(PYTEST_ARGS) + +format: ## Format handwritten Python. + $(UV_RUN) ruff format . + +format-check: ## Check formatting without changing files. + $(UV_RUN) ruff format --check . + +lint: ## Run Ruff lint checks. + $(UV_RUN) ruff check . + +lint-fix: ## Apply safe Ruff fixes. + $(UV_RUN) ruff check --fix . + +typecheck: ## Run curated ty checks. + $(UV_RUN) ty check + +fix: ## Apply Ruff fixes, then format. + $(UV_RUN) ruff check --fix . + $(UV_RUN) ruff format . + +import-check: ## Smoke-test the installed package import. + $(UV_RUN) python -c "import privacy_guard" + +build: ## Build the source distribution and wheel. + $(UV) build + +check: ## Run the authoritative full project checks. + scripts/check.sh + +check-py311: ## Run full checks on the minimum Python version. + scripts/check.sh --python 3.11 + +clean: ## Remove build, test, lint, and Python cache artifacts. + rm -rf build dist .pytest_cache .ruff_cache + find src tests examples scripts -type d -name __pycache__ -prune -exec rm -rf {} + diff --git a/projects/privacy-guard/README.md b/projects/privacy-guard/README.md new file mode 100644 index 0000000..efb98dd --- /dev/null +++ b/projects/privacy-guard/README.md @@ -0,0 +1,312 @@ +# Privacy Guard + +Privacy Guard is an OpenShell supervisor middleware that detects and optionally +replaces sensitive entities in provider-bound request text before credentials +are attached. + +This release is a clean-break redesign. It does not preserve the former +`Scanner`, `FormatHandler`, JSON traversal, `observe`, `redact`, startup catalog, +or scanner-profile APIs. A processor run accepts one UTF-8 text body and runs +the policy's entity-processing stages in order. + +## Policy experience + +The OpenShell policy owns entity behavior: ordered stages, each engine's exact +configuration, and the final `detect`, `block`, or `replace` action. + +```yaml +entity_processing: + stages: + - name: identifiers + config: + engine: regex + pattern_catalog: + entities: + - name: email + rules: + - pattern: '(? finalized Pydantic policy union (config.engine discriminator) + -> reuse the matching active RequestProcessor, or fully prepare and + atomically activate a replacement + -> strict UTF-8 decode + -> RequestProcessor.process(one text string) + -> stage 1 engine.run(current text) + -> stage 2 engine.run(stage 1 text) + -> ... + -> policy action: detect, block, or replace + -> safe aggregated entity findings + -> OpenShell HttpRequestResult +``` + +The policy action never crosses the engine boundary. Engines receive only +`EntityProcessingStrategy.DETECT` or `EntityProcessingStrategy.REPLACE`. +Blocking is a request-level disposition owned by `RequestProcessor`. + +The copied `proto/supervisor_middleware.proto` and generated bindings are owned +by OpenShell. Update them only through the repository's middleware-kit workflow; +never hand-edit them. Today's protocol carries a `google.protobuf.Struct` +configuration on each evaluation. Privacy Guard validates it every time and +retains one active configured processor. The first evaluation prepares that +processor. A different validated configuration is fully prepared and atomically +replaces it only after preparation succeeds; failure leaves the active processor +unchanged. + +The protocol has no explicit update or policy-version marker, so any changed +per-evaluation configuration is treated as an update. Each Privacy Guard process +must therefore receive one consistent policy stream. Large-catalog preparation +RPCs, versioned policy references, manifest schema fields, and a dedicated +finding-source field require a coordinated change in the canonical OpenShell +protocol rather than a private proto fork. + +## Built-in engines + +### RegexEngine + +`RegexEngine` compiles configured rules once and supports overlapping +detection and deterministic template replacement. It preserves numeric +backreferences by wrapping each rule's pattern in a non-capturing group followed +by a private named marker. Rule names are optional diagnostic identities; +`pattern` is the only field containing the regex string. Equivalent +normalized catalogs reuse a bounded compiled-rule cache across validation and +engine construction. + +The third-party `regex` backend provides enforceable per-search timeouts. +Explicit `ignore_case`, `multiline`, `dot_all`, and `ascii` flags are supported; +inline flags and user-defined named groups are rejected to protect the wrapper +contract. + +Privacy Guard owns the catalog schema but maintains no authoritative patterns. + +## Custom engines + +Custom engines are a first-class extension point. Authors declare one typed +config, optional typed resources, `supported_strategies`, and `_run`. They do not +write `__init__`; `_initialize` is optional, and `@override` is not required. +The public wrapper validates strategy support, input, timeout, spans, output +size, mutation behavior, and result cardinality for every returned result. +`TextProcessingResult.from_detections()` is an optional safe materializer for +lazy detection streams, not the enforcement boundary. + +Resource-backed engines define an `EngineResources` subclass containing their +operator-owned runtime dependencies. Resource bundles may contain initialized +tool clients, SDK adapters, models, endpoints, or credential providers, but +must contain no policy behavior or per-request state and must be safe for +concurrent use. Resource-free engines omit the second +`EntityProcessingEngine` generic argument entirely. + +The first NeMo Anonymizer integration will be implemented as a custom engine, +not as a built-in or placeholder abstraction in Privacy Guard. + +Application startup registers engines and operator-owned resources, then +returns one finalized registry: + +```python +from privacy_guard.engines.registry import EngineRegistry + + +def create_registry() -> EngineRegistry: + registry = EngineRegistry(include_builtin_engines=True) + registry.register(AcmeEngine, resources=AcmeResources(client=client)) + return registry.finalize() +``` + +Set `include_builtin_engines=True` when a custom deployment should extend the +standard engine inventory. Leave it at the default `False` for an intentionally +isolated registry. + +Pass that factory to every CLI operation so discovery, schema generation, and +the running server use the same engine inventory. The module must be installed +or otherwise present on Python's import path: + +```bash +uv run privacy-guard --registry-factory my_engines:create_registry engines +uv run privacy-guard --registry-factory my_engines:create_registry configuration-schema +uv run privacy-guard --registry-factory my_engines:create_registry serve +``` + +The +[custom engine end-to-end example](https://github.com/NVIDIA/OpenShell-Research/blob/main/projects/privacy-guard/examples/custom-engine/README.md) +contains one Python file with a typed detection config, engine implementation, +and registry factory, plus its OpenShell policy and walkthrough. It also shows +the explicit `PYTHONPATH` setup needed when the factory is a standalone local +module rather than an installed package. + +The registry is application-scoped, not a process-global singleton. A +`PrivacyGuardServer` requires an explicit finalized registry. The finalized +registry builds a Pydantic discriminated union containing the exact config type +of every registered engine, so `stage.config` round-trips without dropping +engine-specific or replacement-variant fields. + +The base installation has an explicit built-in registry containing +`RegexEngine`: + +```python +from privacy_guard.engines.registry import create_builtin_registry + +registry = create_builtin_registry() +``` + +## Programmatic server + +The server is a library API independent of the command-line application: + +```python +from privacy_guard.engines.registry import create_builtin_registry +from privacy_guard.service import PrivacyGuardServer + +server = PrivacyGuardServer( + create_builtin_registry(), + timeout_seconds=5, +) +server.serve_sync("127.0.0.1:50051") +``` + +`serve_sync()` is the blocking entry point. Async applications use the +explicitly named asynchronous counterpart: + +```python +await server.serve_async("127.0.0.1:50051") +``` + +Listen addresses use `host:port` or bracketed IPv6 `[address]:port` form. The +port must be an explicit integer from 1 through 65535. + +Custom applications pass their own finalized registry to +`PrivacyGuardServer`. The server owns the middleware adapter, bounded gRPC +transport, worker executor, and shutdown lifecycle; it does not load Python +modules, select engines, generate schemas, or parse command-line options. + +Privacy Guard emits records through Python's standard `privacy_guard` logger +and does not configure logging when imported. Applications may use their own +standard-library logging setup or opt into Privacy Guard's concise console +format: + +```python +from privacy_guard.logging import ColorMode, LoggingConfig, configure_logging + +configure_logging(LoggingConfig(level="DEBUG", color_mode=ColorMode.AUTO)) +``` + +Repeated calls replace only the handler installed by `configure_logging()`; +application-owned handlers are preserved. The CLI uses this same configuration +path. The default format includes the date, time, padded level, logger name, +and message. It adds level-aware ANSI colors when writing to an interactive +terminal and stays plain when redirected. Set `color_mode=ColorMode.ALWAYS` or +`color_mode=ColorMode.NEVER` for an explicit choice instead of the default +`ColorMode.AUTO`. Its default level is `INFO`, and `--debug` enables +content-safe `DEBUG` diagnostics. `--debug-log-content` additionally logs +complete request and processed text and should be enabled only in a controlled +environment. + +## CLI + +```bash +uv run privacy-guard engines +uv run privacy-guard configuration-schema +uv run privacy-guard configure-gateway --host-ip YOUR_HOST_IPV4 +uv run privacy-guard serve \ + --listen 127.0.0.1:50051 \ + --timeout-seconds 5 +``` + +Replace `YOUR_HOST_IPV4` with the non-loopback IPv4 address that the OpenShell +gateway and sandbox supervisors use to reach this host. `configure-gateway` +adds or updates a registration named `privacy-guard` in the path set by +OpenShell's `OPENSHELL_GATEWAY_CONFIG` override, or OpenShell's standard +per-user gateway config location when that variable is unset: +`$XDG_CONFIG_HOME/openshell/gateway.toml` (normally +`~/.config/openshell/gateway.toml`). Privacy Guard does not guess which host +interface is correct. Use `--name` when the policy references a different +registration name, `--port` when Privacy Guard does not listen on 50051, and +`--config` for a nonstandard gateway TOML. OpenShell middleware registration +names must use 1–128 ASCII bytes containing only letters, digits, `.`, `_`, `-`, +or `/`; the `openshell/` prefix is reserved. The command preserves unrelated +settings and updates the named registration when it already exists. Start +Privacy Guard before restarting the gateway, because OpenShell connects to +registered middleware during startup. + +Entity behavior is supplied by OpenShell policy config, not server startup +flags. Deployment startup owns only installed engine implementations and +operator resources such as model profiles, endpoints, clients, and credentials. +The processing timeout is an operational bound shared by every stage; it +defaults to 1 second and may be increased to at most 30 seconds. When increasing +it, configure OpenShell's middleware `timeout` with additional headroom for +worker queueing and configuration or engine preparation so the supervisor does +not end the RPC first. Use `--registry-factory module:factory` for a custom +engine installation. +Registry factories execute operator Python code; load only trusted modules. +The `privacy_guard.cli` module owns the executable application and adapts its +`serve` command to the programmatic server API. + +## Safety and limits + +- Input and replacement bodies are limited to 4 MiB. +- One monotonic `Timeout` is shared across every stage and result validation. +- Regex searches receive the remaining timeout and fail atomically. +- Intermediate text and detection cardinality are bounded. +- Detect and block never return a body mutation; replace returns final text. +- Findings expose entity, bounded confidence, count, and stage provenance. + Framework-controlled fields and compliant custom engines never expose matched + or surrounding text, offsets, patterns, or raw tool metadata. Custom engines + must return stable, declared identifiers rather than request-derived values. +- Engine instances and injected resources must be safe for concurrent requests. +- Cross-request entity memory is intentionally out of scope. + +## Updating the OpenShell protocol + +Privacy Guard uses +[`openshell-middleware-kit`](https://github.com/NVIDIA/OpenShell-Research/blob/main/projects/openshell-middleware-kit/README.md) +to keep its copied protocol and generated Python bindings aligned with an +OpenShell release. +Install the repository's local `omkit`, then update: + +```bash +uv tool install --force ../openshell-middleware-kit +omkit update --openshell-version v0.0.90 +``` + +The updater replaces only the copied protocol, generated bindings, lockfile, and +`.openshell-middleware-manifest.json` from a validated temporary copy. Review +those generated changes and run `make check`. + +## Development validation + +The project Makefile exposes the normal workflow: + +```bash +make help +make test PYTEST_ARGS="tests/test_request_processor.py" +make fix +make check +make check-py311 +``` + +`make check` delegates to `scripts/check.sh`, the authoritative local and CI +gate. It runs tests, formatting, lint, `ty`, an import smoke check, and a +dependency audit. diff --git a/projects/privacy-guard/docs/architecture b/projects/privacy-guard/docs/architecture new file mode 120000 index 0000000..d63f714 --- /dev/null +++ b/projects/privacy-guard/docs/architecture @@ -0,0 +1 @@ +../../../docs/documentation/privacy-guard/architecture \ No newline at end of file diff --git a/projects/privacy-guard/docs/index.md b/projects/privacy-guard/docs/index.md new file mode 100644 index 0000000..f006a10 --- /dev/null +++ b/projects/privacy-guard/docs/index.md @@ -0,0 +1,6 @@ +# Privacy Guard documentation + +Technical documentation for people and coding agents working on Privacy Guard. + +- [Architecture](architecture/index.md): component boundaries, request flow, + extension interfaces, concurrency, failures, and limits diff --git a/projects/privacy-guard/examples/custom-engine/.gitignore b/projects/privacy-guard/examples/custom-engine/.gitignore new file mode 100644 index 0000000..b223234 --- /dev/null +++ b/projects/privacy-guard/examples/custom-engine/.gitignore @@ -0,0 +1 @@ +gateway.local.toml diff --git a/projects/privacy-guard/examples/custom-engine/README.md b/projects/privacy-guard/examples/custom-engine/README.md new file mode 100644 index 0000000..e64f860 --- /dev/null +++ b/projects/privacy-guard/examples/custom-engine/README.md @@ -0,0 +1,258 @@ +# Custom engine end-to-end example + +This example implements and registers `KeywordEngine` in one Python file, then +runs it through Privacy Guard and OpenShell. The final check sends a Claude Code +request containing `Project Cobalt` and verifies that Privacy Guard reports the +configured confidential-project finding. + +The implementation is intentionally compact but complete: + +- `KeywordEngineConfig` defines the policy-owned entity and keyword. +- `KeywordEngine._run()` finds every literal occurrence and returns detections. +- `TextProcessingResult.from_detections()` bounds the lazy detection stream. +- `create_registry()` includes the built-in engines and registers the custom + implementation for every CLI command. + +The base engine wrapper validates strategy support, input, timeout, spans, +output size, mutation behavior, and result cardinality. Custom engines add +their own checks only when an underlying library or service has a unique +low-level requirement. + +## Prerequisites + +This walkthrough targets the protocol and policy schema in OpenShell `v0.0.90`, +the version recorded in Privacy Guard's `.openshell-middleware-manifest.json`. +Other OpenShell releases may have different middleware configuration or CLI +syntax. + +Before starting, have: + +- Python 3.11 or newer and `uv` 0.11 or newer +- OpenShell `v0.0.90`, installed with its package-managed local gateway +- a running Docker or Podman backend supported by OpenShell +- Claude Code subscription access if you want to perform the final provider call + +The gateway lifecycle commands below cover macOS Homebrew and Linux Debian/RPM +installations. Snap, Kubernetes, remote, and custom gateway deployments need +equivalent service-management, TLS, and middleware-routing configuration. + +Confirm the important versions: + +```bash +uv --version +openshell --version +openshell-gateway --version +``` + +Run every command below from this example directory. In each new terminal, +repeat the `cd` command: + +```bash +cd projects/privacy-guard/examples/custom-engine +uv sync --locked +``` + +## Inspect the custom installation + +The console script does not automatically add its current directory to Python's +module path. Export it explicitly so the local example modules are importable: + +```bash +export PYTHONPATH="$PWD${PYTHONPATH:+:$PYTHONPATH}" +``` + +Use the same custom registry for discovery, schema generation, and serving: + +```bash +uv run privacy-guard \ + --registry-factory custom_engine:create_registry \ + engines + +uv run privacy-guard \ + --registry-factory custom_engine:create_registry \ + configuration-schema +``` + +The first command should print the built-in `regex` row with `detect,replace` +and the custom `keyword-tool` row with `detect`. The schema should contain both +`RegexEngineConfig` and `KeywordEngineConfig`; the latter includes its exact +`entity` and `keyword` fields. Registry factories execute operator Python code +in the Privacy Guard process; use only trusted modules. + +`privacy-guard-config.yaml` shows the standalone engine configuration. OpenShell +does not load that file separately; `policy.yaml` contains the same configuration +inline under `network_middlewares`. + +## Start Privacy Guard + +In terminal 1, enter this example directory, export `PYTHONPATH` again, and run: + +```bash +export PYTHONPATH="$PWD${PYTHONPATH:+:$PYTHONPATH}" + +uv run privacy-guard \ + --registry-factory custom_engine:create_registry \ + serve \ + --listen 0.0.0.0:50051 +``` + +Leave this terminal running. The development server is unauthenticated +plaintext gRPC and receives potentially sensitive request bodies. Binding to +`0.0.0.0` is necessary for the sandbox supervisor to reach it, but port 50051 +must remain restricted to the host and trusted sandbox network. + +## Create the gateway configuration + +The gateway and sandbox supervisor must both be able to reach Privacy Guard. +Find a non-loopback IPv4 address for the physical Ethernet or Wi-Fi interface: + +```bash +# macOS examples; use the interface that is actually connected. +ipconfig getifaddr en0 +ipconfig getifaddr en1 + +# Linux: inspect the addresses and choose the LAN address. +hostname -I +``` + +In terminal 2, from this example directory, assign the selected address and +generate the local gateway configuration: + +```bash +YOUR_HOST_IP=YOUR_HOST_IPV4 +uv run privacy-guard configure-gateway \ + --host-ip "$YOUR_HOST_IP" \ + --name privacy-guard-custom-engine \ + --config gateway.local.toml +``` + +Replace `YOUR_HOST_IPV4` with the address you selected. Do not use +`127.0.0.1`, a VPN address, or `host.openshell.internal`: the foreground gateway +process and the sandbox supervisor must both be able to resolve and reach the +configured endpoint. This walkthrough passes `--config` to keep its generated +file local and disposable. Without that option, the command uses OpenShell's +`OPENSHELL_GATEWAY_CONFIG` override when set, otherwise its standard per-user +gateway config location at `$XDG_CONFIG_HOME/openshell/gateway.toml`, normally +`~/.config/openshell/gateway.toml`. + +## Restart the local gateway with middleware enabled + +The installed gateway does not dynamically reload middleware registrations. +Stop its package-managed service, then run the same gateway binary in the +foreground with `gateway.local.toml`. + +Run the command for your host: + +```bash +# macOS/Homebrew +brew services stop openshell + +# Linux Debian/RPM package +systemctl --user stop openshell-gateway +``` + +Still in terminal 2, select the package-managed TLS directory for your host and +start the gateway: + +```bash +# macOS/Homebrew +export OPENSHELL_LOCAL_TLS_DIR="$HOME/.local/state/openshell/homebrew/tls" + +# Linux Debian/RPM package +export OPENSHELL_LOCAL_TLS_DIR="$HOME/.local/state/openshell/tls" + +openshell-gateway --config "$PWD/gateway.local.toml" +``` + +Run only one `export` line. Leave the foreground gateway running. + +## Verify OpenShell and create the sandbox + +The package installer normally creates an `openshell` gateway registration. +Reuse it; attempting to add another gateway with that name fails because it +already exists. + +In terminal 3, from this example directory: + +```bash +openshell gateway select openshell +openshell status +``` + +Do not continue until status reports the foreground gateway as connected. +Then create the sandbox: + +```bash +openshell sandbox create \ + --name privacy-guard-custom-engine \ + --from base \ + --no-auto-providers \ + --policy "$PWD/policy.yaml" \ + -- env CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 claude +``` + +Sandbox creation validates the external middleware registration and the exact +`KeywordEngineConfig` embedded in the policy. A successful creation is therefore +also the end-to-end configuration check. + +After authenticating Claude Code, enter: + +```text +Tell me something that rhymes with the confidential name Project Cobalt +``` + +This detection-only example leaves the request body unchanged and records the +finding before the provider request continues. + +## Verify the middleware result + +Do not infer success from the model's wording. From another host terminal, +inspect a finite recent log window: + +```bash +openshell logs privacy-guard-custom-engine -n 100 --source sandbox +``` + +Look for the `api.anthropic.com/v1/messages` request with `transformed:false` +and a `confidential-project (project-names)` finding. The raw confidential +value must not appear in middleware findings. + +## Cleanup + +Exit Claude and delete the sandbox: + +```bash +openshell sandbox delete privacy-guard-custom-engine +``` + +Stop the foreground gateway and Privacy Guard with `Ctrl-C`. Restore the +package-managed gateway with the command for your host: + +```bash +# macOS/Homebrew +brew services start openshell + +# Linux Debian/RPM package +systemctl --user start openshell-gateway +``` + +Verify recovery and remove the generated configuration: + +```bash +openshell gateway select openshell +openshell status +rm gateway.local.toml +``` + +## Troubleshooting + +- `registry factory could not be loaded`: export `PYTHONPATH` in the terminal + running `privacy-guard`. +- Port 17670 is already in use: the package-managed gateway was not stopped. +- The foreground gateway cannot find certificates: use the TLS directory for + your platform exactly as shown above. +- Sandbox creation reports unavailable middleware: confirm terminal 1 is still + running, check the IP in `gateway.local.toml`, and allow trusted sandbox + traffic to host port 50051. +- Policy or middleware registration fields are rejected: confirm both + `openshell` and `openshell-gateway` are from `v0.0.90`. diff --git a/projects/privacy-guard/examples/custom-engine/custom_engine.py b/projects/privacy-guard/examples/custom-engine/custom_engine.py new file mode 100644 index 0000000..fb6d8f1 --- /dev/null +++ b/projects/privacy-guard/examples/custom-engine/custom_engine.py @@ -0,0 +1,62 @@ +"""Complete custom entity-processing engine example.""" + +from __future__ import annotations + +import re +from typing import Literal + +from pydantic import Field + +from privacy_guard.engines import ( + ConfidenceLevel, + EngineConfig, + EntityDetection, + EntityName, + EntityProcessingEngine, + EntityProcessingStrategy, + TextProcessingResult, +) +from privacy_guard.engines.registry import EngineRegistry +from privacy_guard.timeout import Timeout + + +class KeywordEngineConfig(EngineConfig): + """Policy-owned configuration for keyword detection.""" + + engine: Literal["keyword-tool"] = "keyword-tool" + entity: EntityName + keyword: str = Field(min_length=1, max_length=256, repr=False) + + +class KeywordEngine(EntityProcessingEngine[KeywordEngineConfig]): + """Detect every occurrence of one configured keyword.""" + + supported_strategies = frozenset({EntityProcessingStrategy.DETECT}) + + def _run( + self, + text: str, + *, + strategy: EntityProcessingStrategy, + timeout: Timeout, + ) -> TextProcessingResult: + matches = re.finditer(re.escape(self.config.keyword), text) + return TextProcessingResult.from_detections( + text=text, + detections=( + EntityDetection( + entity=self.config.entity, + start=match.start(), + end=match.end(), + confidence=ConfidenceLevel.HIGH, + ) + for match in matches + ), + ) + + +def create_registry() -> EngineRegistry: + """Create a registry containing the built-in and custom engines.""" + registry = EngineRegistry(include_builtin_engines=True) + registry.register(KeywordEngine) + return registry.finalize() diff --git a/projects/privacy-guard/examples/custom-engine/policy.yaml b/projects/privacy-guard/examples/custom-engine/policy.yaml new file mode 100644 index 0000000..61369d0 --- /dev/null +++ b/projects/privacy-guard/examples/custom-engine/policy.yaml @@ -0,0 +1,53 @@ +version: 1 + +filesystem_policy: + include_workdir: true + read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log] + read_write: [/sandbox, /tmp, /dev/null] +landlock: + compatibility: best_effort +process: + run_as_user: sandbox + run_as_group: sandbox + +network_policies: + claude_code: + name: Claude Code subscription access + endpoints: + - host: api.anthropic.com + port: 443 + protocol: rest + enforcement: enforce + access: full + - host: platform.claude.com + port: 443 + protocol: rest + enforcement: enforce + access: full + - host: claude.ai + port: 443 + - { host: statsig.anthropic.com, port: 443 } + - { host: sentry.io, port: 443 } + binaries: + - { path: /usr/local/bin/claude } + - { path: /usr/bin/node } + +network_middlewares: + privacy_guard_detect: + name: Detect confidential project names + middleware: privacy-guard-custom-engine + order: 0 + config: + entity_processing: + stages: + - name: project-names + config: + engine: keyword-tool + entity: confidential-project + keyword: Project Cobalt + on_detection: + action: detect + on_error: fail_closed + endpoints: + include: + - api.anthropic.com diff --git a/projects/privacy-guard/examples/custom-engine/privacy-guard-config.yaml b/projects/privacy-guard/examples/custom-engine/privacy-guard-config.yaml new file mode 100644 index 0000000..1ee1d76 --- /dev/null +++ b/projects/privacy-guard/examples/custom-engine/privacy-guard-config.yaml @@ -0,0 +1,9 @@ +entity_processing: + stages: + - name: project-names + config: + engine: keyword-tool + entity: confidential-project + keyword: Project Cobalt +on_detection: + action: detect diff --git a/projects/privacy-guard/examples/regex-engine/.gitignore b/projects/privacy-guard/examples/regex-engine/.gitignore new file mode 100644 index 0000000..b223234 --- /dev/null +++ b/projects/privacy-guard/examples/regex-engine/.gitignore @@ -0,0 +1 @@ +gateway.local.toml diff --git a/projects/privacy-guard/examples/regex-engine/README.md b/projects/privacy-guard/examples/regex-engine/README.md new file mode 100644 index 0000000..255de36 --- /dev/null +++ b/projects/privacy-guard/examples/regex-engine/README.md @@ -0,0 +1,223 @@ +# RegexEngine end-to-end example + +This example runs Privacy Guard's built-in `RegexEngine` through OpenShell. The +final check sends a Claude Code request containing an email address and customer +ID, then verifies that OpenShell forwards `[email]` and `[customer-id]`. + +Privacy Guard does not ship authoritative regex presets. Copy and adapt +`patterns.yaml` for the data you actually need to identify, and test every +pattern against representative matches, non-matches, and worst-case inputs +before deployment. + +## Prerequisites + +This walkthrough targets OpenShell `v0.0.90`, the version recorded in Privacy +Guard's `.openshell-middleware-manifest.json`. Other releases may use different +middleware configuration or CLI syntax. + +Before starting, have: + +- Python 3.11 or newer and `uv` 0.11 or newer +- OpenShell `v0.0.90`, installed with its package-managed local gateway +- a running Docker or Podman backend supported by OpenShell +- Claude Code subscription access if you want to perform the final provider call + +The gateway lifecycle commands below cover macOS Homebrew and Linux Debian/RPM +installations. Snap, Kubernetes, remote, and custom gateway deployments need +equivalent service-management, TLS, and middleware-routing configuration. + +Confirm the versions: + +```bash +uv --version +openshell --version +openshell-gateway --version +``` + +Run every command below from this example directory. In each new terminal, +repeat the `cd` command: + +```bash +cd projects/privacy-guard/examples/regex-engine +uv sync --locked +``` + +## Inspect the built-in installation + +```bash +uv run privacy-guard engines +uv run privacy-guard configuration-schema +``` + +The first command should print one `regex` row with `detect,replace`. The schema +should contain `RegexEngineConfig`, `RegexPatternCatalog`, `RegexRule`, and +`RegexReplacement`. + +`privacy-guard-config.yaml` is the standalone Privacy Guard configuration. +`policy.yaml` contains the same configuration inline under +`network_middlewares`, which is the form OpenShell sends to the middleware. +Both configurations pass `patterns.yaml` directly as the complete +`pattern_catalog`. Privacy Guard resolves that relative path from its working +directory, safely loads the YAML file, and validates it with the same +`RegexPatternCatalog` model used for an inline catalog. + +Catalog paths must be relative `.yaml` or `.yml` paths beneath Privacy Guard's +working directory. Absolute paths, `..` traversal, and symlinks are rejected. +This example therefore starts Privacy Guard from this directory. Restart the +service from the same directory whenever it is stopped. + +## Start Privacy Guard + +In terminal 1, from this example directory: + +```bash +uv run privacy-guard serve --listen 0.0.0.0:50051 +``` + +Leave this terminal running. The development server is unauthenticated +plaintext gRPC and receives potentially sensitive request bodies. Binding to +`0.0.0.0` is necessary for the sandbox supervisor to reach it, but port 50051 +must remain restricted to the host and trusted sandbox network. + +## Create the gateway configuration + +Find a non-loopback IPv4 address for the physical Ethernet or Wi-Fi interface: + +```bash +# macOS examples; use the interface that is actually connected. +ipconfig getifaddr en0 +ipconfig getifaddr en1 + +# Linux: inspect the addresses and choose the LAN address. +hostname -I +``` + +In terminal 2, from this example directory, assign the selected address: + +```bash +YOUR_HOST_IP=YOUR_HOST_IPV4 +uv run privacy-guard configure-gateway \ + --host-ip "$YOUR_HOST_IP" \ + --name privacy-guard-regex \ + --config gateway.local.toml +``` + +Replace `YOUR_HOST_IPV4` with the address you selected. Do not use +`127.0.0.1`, a VPN address, or `host.openshell.internal`: the foreground gateway +process and the sandbox supervisor must both be able to resolve and reach the +configured endpoint. This walkthrough passes `--config` to keep its generated +file local and disposable. Without that option, the command uses OpenShell's +`OPENSHELL_GATEWAY_CONFIG` override when set, otherwise its standard per-user +gateway config location at `$XDG_CONFIG_HOME/openshell/gateway.toml`, normally +`~/.config/openshell/gateway.toml`. + +## Restart the local gateway with middleware enabled + +The installed gateway does not dynamically reload middleware registrations. +Stop its package-managed service with the command for your host: + +```bash +# macOS/Homebrew +brew services stop openshell + +# Linux Debian/RPM package +systemctl --user stop openshell-gateway +``` + +Still in terminal 2, select the package-managed TLS directory for your host and +start the gateway: + +```bash +# macOS/Homebrew +export OPENSHELL_LOCAL_TLS_DIR="$HOME/.local/state/openshell/homebrew/tls" + +# Linux Debian/RPM package +export OPENSHELL_LOCAL_TLS_DIR="$HOME/.local/state/openshell/tls" + +openshell-gateway --config "$PWD/gateway.local.toml" +``` + +Run only one `export` line. Leave the foreground gateway running. + +## Verify OpenShell and create the sandbox + +In terminal 3, from this example directory: + +```bash +openshell gateway select openshell +openshell status +``` + +Do not continue until status reports the foreground gateway as connected. +Then create the sandbox: + +```bash +openshell sandbox create \ + --name privacy-guard-regex \ + --from base \ + --no-auto-providers \ + --policy "$PWD/policy.yaml" \ + -- env CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 claude +``` + +Sandbox creation validates the external middleware registration and the exact +`RegexEngineConfig` embedded in the policy. + +After authenticating Claude Code, enter: + +```text +Draft a short greeting for user@example.com about customer CUST-12345678. +``` + +Privacy Guard should send `[email]` and `[customer-id]` instead of the original +identifiers to the provider. + +## Verify the middleware result + +From another host terminal: + +```bash +openshell logs privacy-guard-regex -n 100 --source sandbox +``` + +Look for the `api.anthropic.com/v1/messages` request with `transformed:true`, +plus `email (identifiers)` and `customer-id (identifiers)` findings. Findings +must not contain the matched email address or customer ID. + +## Cleanup + +Exit Claude and delete the sandbox: + +```bash +openshell sandbox delete privacy-guard-regex +``` + +Stop the foreground gateway and Privacy Guard with `Ctrl-C`. Restore the +package-managed gateway with the command for your host: + +```bash +# macOS/Homebrew +brew services start openshell + +# Linux Debian/RPM package +systemctl --user start openshell-gateway +``` + +Verify recovery and remove the generated gateway file: + +```bash +openshell gateway select openshell +openshell status +rm gateway.local.toml +``` + +## Troubleshooting + +- Port 17670 is already in use: the package-managed gateway was not stopped. +- The foreground gateway cannot find certificates: use the TLS directory for + your platform exactly as shown above. +- Sandbox creation reports unavailable middleware: confirm terminal 1 is still + running, check the IP in `gateway.local.toml`, and allow trusted sandbox + traffic to host port 50051. +- Policy or middleware registration fields are rejected: confirm both + `openshell` and `openshell-gateway` are from `v0.0.90`. diff --git a/projects/privacy-guard/examples/regex-engine/patterns.yaml b/projects/privacy-guard/examples/regex-engine/patterns.yaml new file mode 100644 index 0000000..6212dcd --- /dev/null +++ b/projects/privacy-guard/examples/regex-engine/patterns.yaml @@ -0,0 +1,11 @@ +entities: + - name: email + rules: + - name: conventional-email + pattern: '(? metadata = 7; + // Optional stable machine-readable code for a deny decision. Codes must + // start with a lowercase ASCII letter and contain only lowercase ASCII + // letters, digits, and underscores, with a maximum length of 64 bytes. + // OpenShell may return this code to the requester, unlike free-form reason. + string reason_code = 8; +} diff --git a/projects/privacy-guard/pyproject.toml b/projects/privacy-guard/pyproject.toml new file mode 100644 index 0000000..b1f3548 --- /dev/null +++ b/projects/privacy-guard/pyproject.toml @@ -0,0 +1,75 @@ +[project] +name = "privacy-guard" +version = "0.1.0" +description = "Inspect and enforce policy on provider-bound OpenShell requests." +readme = "README.md" +requires-python = ">=3.11" +license = "Apache-2.0" +license-files = ["LICENSE"] +authors = [ + { name = "NVIDIA CORPORATION & AFFILIATES" }, +] +dependencies = [ + "grpcio>=1.81.1,<2", + "protobuf>=6.33.5,<7", + "pydantic>=2.11,<3", + "pyyaml>=6,<7", + "regex>=2026.7.19,<2027", + "typer>=0.16,<1", + "typing-extensions>=4.12,<5", +] + +[project.scripts] +privacy-guard = "privacy_guard.cli:app" + +[project.urls] +Repository = "https://github.com/NVIDIA/OpenShell-Research" + +[dependency-groups] +dev = [ + "pip-audit==2.10.1", + "pytest>=9.0.3,<10", + "pytest-asyncio>=0.25,<2", + "ruff>=0.12,<0.13", + "ty>=0.0.1a16,<0.1", +] + +[build-system] +requires = ["uv_build>=0.11.8,<0.12.0"] +build-backend = "uv_build" + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ruff] +target-version = "py311" +extend-exclude = ["src/privacy_guard/bindings"] + +[tool.ruff.lint] +select = ["E", "F", "I", "UP"] + +[tool.ty.src] +# Generated protobuf/gRPC bindings are checked at their handwritten adapter seam; +# their generator-owned implementations and incomplete annotations stay excluded. +exclude = ["src/privacy_guard/bindings"] + +[tool.ty.rules] +blanket-ignore-comment = "error" +missing-override-decorator = "ignore" +missing-type-argument = "error" +redundant-cast = "error" +unresolved-attribute = "error" +unresolved-global = "error" +unresolved-import = "error" +unresolved-reference = "error" +unused-ignore-comment = "error" +unused-type-ignore-comment = "error" + +[[tool.ty.overrides]] +include = ["examples/**"] + +[tool.ty.overrides.rules] +missing-override-decorator = "ignore" + +[tool.uv] +required-version = ">=0.11.0" diff --git a/projects/privacy-guard/scripts/check.sh b/projects/privacy-guard/scripts/check.sh new file mode 100755 index 0000000..c82e637 --- /dev/null +++ b/projects/privacy-guard/scripts/check.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +uv_run=(uv run --frozen) +if [[ $# -gt 0 ]]; then + if [[ $1 != "--python" || $# -ne 2 ]]; then + echo "usage: scripts/check.sh [--python VERSION]" >&2 + exit 2 + fi + uv_run+=(--python "$2") +fi + +"${uv_run[@]}" pytest -q +"${uv_run[@]}" ruff format --check . +"${uv_run[@]}" ruff check . +"${uv_run[@]}" ty check +"${uv_run[@]}" python -c "import privacy_guard" +"${uv_run[@]}" pip-audit \ + --progress-spinner off \ + --local diff --git a/projects/privacy-guard/src/privacy_guard/__init__.py b/projects/privacy-guard/src/privacy_guard/__init__.py new file mode 100644 index 0000000..da81cd3 --- /dev/null +++ b/projects/privacy-guard/src/privacy_guard/__init__.py @@ -0,0 +1 @@ +"""Privacy Guard: an OpenShell supervisor middleware. See the package README.""" diff --git a/projects/privacy-guard/src/privacy_guard/base.py b/projects/privacy-guard/src/privacy_guard/base.py new file mode 100644 index 0000000..6977d08 --- /dev/null +++ b/projects/privacy-guard/src/privacy_guard/base.py @@ -0,0 +1,18 @@ +"""Package-wide base class for validated, immutable domain objects.""" + +from pydantic import BaseModel, ConfigDict + + +class StrictDomainModel(BaseModel): + """Base for immutable domain values parsed without implicit coercion.""" + + model_config = ConfigDict( + strict=True, + frozen=True, + extra="forbid", + hide_input_in_errors=True, + validate_default=True, + ) + + +__all__ = ["StrictDomainModel"] diff --git a/projects/privacy-guard/src/privacy_guard/bindings/__init__.py b/projects/privacy-guard/src/privacy_guard/bindings/__init__.py new file mode 100644 index 0000000..d206506 --- /dev/null +++ b/projects/privacy-guard/src/privacy_guard/bindings/__init__.py @@ -0,0 +1 @@ +"""Generated OpenShell supervisor middleware bindings. Do not edit.""" diff --git a/projects/privacy-guard/src/privacy_guard/bindings/supervisor_middleware_pb2.py b/projects/privacy-guard/src/privacy_guard/bindings/supervisor_middleware_pb2.py new file mode 100644 index 0000000..c254b0f --- /dev/null +++ b/projects/privacy-guard/src/privacy_guard/bindings/supervisor_middleware_pb2.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: supervisor_middleware.proto +# Protobuf Python Version: 6.33.5 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 6, + 33, + 5, + '', + 'supervisor_middleware.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2 +from google.protobuf import struct_pb2 as google_dot_protobuf_dot_struct__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1bsupervisor_middleware.proto\x12\x17openshell.middleware.v1\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1cgoogle/protobuf/struct.proto\"y\n\x12MiddlewareManifest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x17\n\x0fservice_version\x18\x02 \x01(\t\x12<\n\x08\x62indings\x18\x03 \x03(\x0b\x32*.openshell.middleware.v1.MiddlewareBinding\"\xca\x01\n\x11MiddlewareBinding\x12I\n\toperation\x18\x01 \x01(\x0e\x32\x36.openshell.middleware.v1.SupervisorMiddlewareOperation\x12\x41\n\x05phase\x18\x02 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x16\n\x0emax_body_bytes\x18\x03 \x01(\x04\x12\x0f\n\x07timeout\x18\x04 \x01(\t\"Y\n\x15ValidateConfigRequest\x12\'\n\x06\x63onfig\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x17\n\x0fmiddleware_name\x18\x02 \x01(\t\"7\n\x16ValidateConfigResponse\x12\r\n\x05valid\x18\x01 \x01(\x08\x12\x0e\n\x06reason\x18\x02 \x01(\t\"\xd6\x02\n\x15HttpRequestEvaluation\x12\x41\n\x05phase\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12\'\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12:\n\x06target\x18\x04 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x34\n\x07headers\x18\x05 \x03(\x0b\x32#.openshell.middleware.v1.HttpHeader\x12\x0c\n\x04\x62ody\x18\x06 \x01(\x0c\x12\x17\n\x0fmiddleware_name\x18\x07 \x01(\t\")\n\nHttpHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\"w\n\x0eRequestContext\x12\x12\n\nrequest_id\x18\x01 \x01(\t\x12\x12\n\nsandbox_id\x18\x02 \x01(\t\x12=\n\x13originating_process\x18\x03 \x01(\x0b\x32 .openshell.middleware.v1.Process\"l\n\x11HttpRequestTarget\x12\x0e\n\x06scheme\x18\x01 \x01(\t\x12\x0c\n\x04host\x18\x02 \x01(\t\x12\x0c\n\x04port\x18\x03 \x01(\r\x12\x0e\n\x06method\x18\x04 \x01(\t\x12\x0c\n\x04path\x18\x05 \x01(\t\x12\r\n\x05query\x18\x06 \x01(\t\"9\n\x07Process\x12\x0e\n\x06\x62inary\x18\x01 \x01(\t\x12\x0b\n\x03pid\x18\x02 \x01(\r\x12\x11\n\tancestors\x18\x03 \x03(\t\"[\n\x07\x46inding\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\r\n\x05label\x18\x02 \x01(\t\x12\r\n\x05\x63ount\x18\x03 \x01(\r\x12\x12\n\nconfidence\x18\x04 \x01(\t\x12\x10\n\x08severity\x18\x05 \x01(\t\"n\n\x0bWriteHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\x12\x42\n\x0bon_existing\x18\x03 \x01(\x0e\x32-.openshell.middleware.v1.ExistingHeaderAction\"\x1c\n\x0cRemoveHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\"\x8d\x01\n\x0eHeaderMutation\x12\x35\n\x05write\x18\x01 \x01(\x0b\x32$.openshell.middleware.v1.WriteHeaderH\x00\x12\x37\n\x06remove\x18\x02 \x01(\x0b\x32%.openshell.middleware.v1.RemoveHeaderH\x00\x42\x0b\n\toperation\"\x81\x03\n\x11HttpRequestResult\x12\x33\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x0c\n\x04\x62ody\x18\x03 \x01(\x0c\x12\x10\n\x08has_body\x18\x04 \x01(\x08\x12\x41\n\x10header_mutations\x18\x05 \x03(\x0b\x32\'.openshell.middleware.v1.HeaderMutation\x12\x32\n\x08\x66indings\x18\x06 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12J\n\x08metadata\x18\x07 \x03(\x0b\x32\x38.openshell.middleware.v1.HttpRequestResult.MetadataEntry\x12\x13\n\x0breason_code\x18\x08 \x01(\t\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01*\x82\x01\n\x1dSupervisorMiddlewareOperation\x12/\n+SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED\x10\x00\x12\x30\n,SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST\x10\x01*y\n\x19SupervisorMiddlewarePhase\x12+\n\'SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED\x10\x00\x12/\n+SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS\x10\x01*K\n\x08\x44\x65\x63ision\x12\x18\n\x14\x44\x45\x43ISION_UNSPECIFIED\x10\x00\x12\x12\n\x0e\x44\x45\x43ISION_ALLOW\x10\x01\x12\x11\n\rDECISION_DENY\x10\x02*\xa8\x01\n\x14\x45xistingHeaderAction\x12&\n\"EXISTING_HEADER_ACTION_UNSPECIFIED\x10\x00\x12!\n\x1d\x45XISTING_HEADER_ACTION_APPEND\x10\x01\x12$\n EXISTING_HEADER_ACTION_OVERWRITE\x10\x02\x12\x1f\n\x1b\x45XISTING_HEADER_ACTION_SKIP\x10\x03\x32\xcd\x02\n\x14SupervisorMiddleware\x12O\n\x08\x44\x65scribe\x12\x16.google.protobuf.Empty\x1a+.openshell.middleware.v1.MiddlewareManifest\x12q\n\x0eValidateConfig\x12..openshell.middleware.v1.ValidateConfigRequest\x1a/.openshell.middleware.v1.ValidateConfigResponse\x12q\n\x13\x45valuateHttpRequest\x12..openshell.middleware.v1.HttpRequestEvaluation\x1a*.openshell.middleware.v1.HttpRequestResultb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'supervisor_middleware_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_HTTPREQUESTRESULT_METADATAENTRY']._loaded_options = None + _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_options = b'8\001' + _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_start=2037 + _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_end=2167 + _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_start=2169 + _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_end=2290 + _globals['_DECISION']._serialized_start=2292 + _globals['_DECISION']._serialized_end=2367 + _globals['_EXISTINGHEADERACTION']._serialized_start=2370 + _globals['_EXISTINGHEADERACTION']._serialized_end=2538 + _globals['_MIDDLEWAREMANIFEST']._serialized_start=115 + _globals['_MIDDLEWAREMANIFEST']._serialized_end=236 + _globals['_MIDDLEWAREBINDING']._serialized_start=239 + _globals['_MIDDLEWAREBINDING']._serialized_end=441 + _globals['_VALIDATECONFIGREQUEST']._serialized_start=443 + _globals['_VALIDATECONFIGREQUEST']._serialized_end=532 + _globals['_VALIDATECONFIGRESPONSE']._serialized_start=534 + _globals['_VALIDATECONFIGRESPONSE']._serialized_end=589 + _globals['_HTTPREQUESTEVALUATION']._serialized_start=592 + _globals['_HTTPREQUESTEVALUATION']._serialized_end=934 + _globals['_HTTPHEADER']._serialized_start=936 + _globals['_HTTPHEADER']._serialized_end=977 + _globals['_REQUESTCONTEXT']._serialized_start=979 + _globals['_REQUESTCONTEXT']._serialized_end=1098 + _globals['_HTTPREQUESTTARGET']._serialized_start=1100 + _globals['_HTTPREQUESTTARGET']._serialized_end=1208 + _globals['_PROCESS']._serialized_start=1210 + _globals['_PROCESS']._serialized_end=1267 + _globals['_FINDING']._serialized_start=1269 + _globals['_FINDING']._serialized_end=1360 + _globals['_WRITEHEADER']._serialized_start=1362 + _globals['_WRITEHEADER']._serialized_end=1472 + _globals['_REMOVEHEADER']._serialized_start=1474 + _globals['_REMOVEHEADER']._serialized_end=1502 + _globals['_HEADERMUTATION']._serialized_start=1505 + _globals['_HEADERMUTATION']._serialized_end=1646 + _globals['_HTTPREQUESTRESULT']._serialized_start=1649 + _globals['_HTTPREQUESTRESULT']._serialized_end=2034 + _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_start=1987 + _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_end=2034 + _globals['_SUPERVISORMIDDLEWARE']._serialized_start=2541 + _globals['_SUPERVISORMIDDLEWARE']._serialized_end=2874 +# @@protoc_insertion_point(module_scope) diff --git a/projects/privacy-guard/src/privacy_guard/bindings/supervisor_middleware_pb2.pyi b/projects/privacy-guard/src/privacy_guard/bindings/supervisor_middleware_pb2.pyi new file mode 100644 index 0000000..10eac7f --- /dev/null +++ b/projects/privacy-guard/src/privacy_guard/bindings/supervisor_middleware_pb2.pyi @@ -0,0 +1,209 @@ +from google.protobuf import empty_pb2 as _empty_pb2 +from google.protobuf import struct_pb2 as _struct_pb2 +from google.protobuf.internal import containers as _containers +from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from collections.abc import Iterable as _Iterable, Mapping as _Mapping +from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class SupervisorMiddlewareOperation(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED: _ClassVar[SupervisorMiddlewareOperation] + SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST: _ClassVar[SupervisorMiddlewareOperation] + +class SupervisorMiddlewarePhase(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED: _ClassVar[SupervisorMiddlewarePhase] + SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS: _ClassVar[SupervisorMiddlewarePhase] + +class Decision(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + DECISION_UNSPECIFIED: _ClassVar[Decision] + DECISION_ALLOW: _ClassVar[Decision] + DECISION_DENY: _ClassVar[Decision] + +class ExistingHeaderAction(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + EXISTING_HEADER_ACTION_UNSPECIFIED: _ClassVar[ExistingHeaderAction] + EXISTING_HEADER_ACTION_APPEND: _ClassVar[ExistingHeaderAction] + EXISTING_HEADER_ACTION_OVERWRITE: _ClassVar[ExistingHeaderAction] + EXISTING_HEADER_ACTION_SKIP: _ClassVar[ExistingHeaderAction] +SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED: SupervisorMiddlewareOperation +SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST: SupervisorMiddlewareOperation +SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED: SupervisorMiddlewarePhase +SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS: SupervisorMiddlewarePhase +DECISION_UNSPECIFIED: Decision +DECISION_ALLOW: Decision +DECISION_DENY: Decision +EXISTING_HEADER_ACTION_UNSPECIFIED: ExistingHeaderAction +EXISTING_HEADER_ACTION_APPEND: ExistingHeaderAction +EXISTING_HEADER_ACTION_OVERWRITE: ExistingHeaderAction +EXISTING_HEADER_ACTION_SKIP: ExistingHeaderAction + +class MiddlewareManifest(_message.Message): + __slots__ = ("name", "service_version", "bindings") + NAME_FIELD_NUMBER: _ClassVar[int] + SERVICE_VERSION_FIELD_NUMBER: _ClassVar[int] + BINDINGS_FIELD_NUMBER: _ClassVar[int] + name: str + service_version: str + bindings: _containers.RepeatedCompositeFieldContainer[MiddlewareBinding] + def __init__(self, name: _Optional[str] = ..., service_version: _Optional[str] = ..., bindings: _Optional[_Iterable[_Union[MiddlewareBinding, _Mapping]]] = ...) -> None: ... + +class MiddlewareBinding(_message.Message): + __slots__ = ("operation", "phase", "max_body_bytes", "timeout") + OPERATION_FIELD_NUMBER: _ClassVar[int] + PHASE_FIELD_NUMBER: _ClassVar[int] + MAX_BODY_BYTES_FIELD_NUMBER: _ClassVar[int] + TIMEOUT_FIELD_NUMBER: _ClassVar[int] + operation: SupervisorMiddlewareOperation + phase: SupervisorMiddlewarePhase + max_body_bytes: int + timeout: str + def __init__(self, operation: _Optional[_Union[SupervisorMiddlewareOperation, str]] = ..., phase: _Optional[_Union[SupervisorMiddlewarePhase, str]] = ..., max_body_bytes: _Optional[int] = ..., timeout: _Optional[str] = ...) -> None: ... + +class ValidateConfigRequest(_message.Message): + __slots__ = ("config", "middleware_name") + CONFIG_FIELD_NUMBER: _ClassVar[int] + MIDDLEWARE_NAME_FIELD_NUMBER: _ClassVar[int] + config: _struct_pb2.Struct + middleware_name: str + def __init__(self, config: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., middleware_name: _Optional[str] = ...) -> None: ... + +class ValidateConfigResponse(_message.Message): + __slots__ = ("valid", "reason") + VALID_FIELD_NUMBER: _ClassVar[int] + REASON_FIELD_NUMBER: _ClassVar[int] + valid: bool + reason: str + def __init__(self, valid: _Optional[bool] = ..., reason: _Optional[str] = ...) -> None: ... + +class HttpRequestEvaluation(_message.Message): + __slots__ = ("phase", "context", "config", "target", "headers", "body", "middleware_name") + PHASE_FIELD_NUMBER: _ClassVar[int] + CONTEXT_FIELD_NUMBER: _ClassVar[int] + CONFIG_FIELD_NUMBER: _ClassVar[int] + TARGET_FIELD_NUMBER: _ClassVar[int] + HEADERS_FIELD_NUMBER: _ClassVar[int] + BODY_FIELD_NUMBER: _ClassVar[int] + MIDDLEWARE_NAME_FIELD_NUMBER: _ClassVar[int] + phase: SupervisorMiddlewarePhase + context: RequestContext + config: _struct_pb2.Struct + target: HttpRequestTarget + headers: _containers.RepeatedCompositeFieldContainer[HttpHeader] + body: bytes + middleware_name: str + def __init__(self, phase: _Optional[_Union[SupervisorMiddlewarePhase, str]] = ..., context: _Optional[_Union[RequestContext, _Mapping]] = ..., config: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., target: _Optional[_Union[HttpRequestTarget, _Mapping]] = ..., headers: _Optional[_Iterable[_Union[HttpHeader, _Mapping]]] = ..., body: _Optional[bytes] = ..., middleware_name: _Optional[str] = ...) -> None: ... + +class HttpHeader(_message.Message): + __slots__ = ("name", "value") + NAME_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + name: str + value: str + def __init__(self, name: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + +class RequestContext(_message.Message): + __slots__ = ("request_id", "sandbox_id", "originating_process") + REQUEST_ID_FIELD_NUMBER: _ClassVar[int] + SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] + ORIGINATING_PROCESS_FIELD_NUMBER: _ClassVar[int] + request_id: str + sandbox_id: str + originating_process: Process + def __init__(self, request_id: _Optional[str] = ..., sandbox_id: _Optional[str] = ..., originating_process: _Optional[_Union[Process, _Mapping]] = ...) -> None: ... + +class HttpRequestTarget(_message.Message): + __slots__ = ("scheme", "host", "port", "method", "path", "query") + SCHEME_FIELD_NUMBER: _ClassVar[int] + HOST_FIELD_NUMBER: _ClassVar[int] + PORT_FIELD_NUMBER: _ClassVar[int] + METHOD_FIELD_NUMBER: _ClassVar[int] + PATH_FIELD_NUMBER: _ClassVar[int] + QUERY_FIELD_NUMBER: _ClassVar[int] + scheme: str + host: str + port: int + method: str + path: str + query: str + def __init__(self, scheme: _Optional[str] = ..., host: _Optional[str] = ..., port: _Optional[int] = ..., method: _Optional[str] = ..., path: _Optional[str] = ..., query: _Optional[str] = ...) -> None: ... + +class Process(_message.Message): + __slots__ = ("binary", "pid", "ancestors") + BINARY_FIELD_NUMBER: _ClassVar[int] + PID_FIELD_NUMBER: _ClassVar[int] + ANCESTORS_FIELD_NUMBER: _ClassVar[int] + binary: str + pid: int + ancestors: _containers.RepeatedScalarFieldContainer[str] + def __init__(self, binary: _Optional[str] = ..., pid: _Optional[int] = ..., ancestors: _Optional[_Iterable[str]] = ...) -> None: ... + +class Finding(_message.Message): + __slots__ = ("type", "label", "count", "confidence", "severity") + TYPE_FIELD_NUMBER: _ClassVar[int] + LABEL_FIELD_NUMBER: _ClassVar[int] + COUNT_FIELD_NUMBER: _ClassVar[int] + CONFIDENCE_FIELD_NUMBER: _ClassVar[int] + SEVERITY_FIELD_NUMBER: _ClassVar[int] + type: str + label: str + count: int + confidence: str + severity: str + def __init__(self, type: _Optional[str] = ..., label: _Optional[str] = ..., count: _Optional[int] = ..., confidence: _Optional[str] = ..., severity: _Optional[str] = ...) -> None: ... + +class WriteHeader(_message.Message): + __slots__ = ("name", "value", "on_existing") + NAME_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + ON_EXISTING_FIELD_NUMBER: _ClassVar[int] + name: str + value: str + on_existing: ExistingHeaderAction + def __init__(self, name: _Optional[str] = ..., value: _Optional[str] = ..., on_existing: _Optional[_Union[ExistingHeaderAction, str]] = ...) -> None: ... + +class RemoveHeader(_message.Message): + __slots__ = ("name",) + NAME_FIELD_NUMBER: _ClassVar[int] + name: str + def __init__(self, name: _Optional[str] = ...) -> None: ... + +class HeaderMutation(_message.Message): + __slots__ = ("write", "remove") + WRITE_FIELD_NUMBER: _ClassVar[int] + REMOVE_FIELD_NUMBER: _ClassVar[int] + write: WriteHeader + remove: RemoveHeader + def __init__(self, write: _Optional[_Union[WriteHeader, _Mapping]] = ..., remove: _Optional[_Union[RemoveHeader, _Mapping]] = ...) -> None: ... + +class HttpRequestResult(_message.Message): + __slots__ = ("decision", "reason", "body", "has_body", "header_mutations", "findings", "metadata", "reason_code") + class MetadataEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + DECISION_FIELD_NUMBER: _ClassVar[int] + REASON_FIELD_NUMBER: _ClassVar[int] + BODY_FIELD_NUMBER: _ClassVar[int] + HAS_BODY_FIELD_NUMBER: _ClassVar[int] + HEADER_MUTATIONS_FIELD_NUMBER: _ClassVar[int] + FINDINGS_FIELD_NUMBER: _ClassVar[int] + METADATA_FIELD_NUMBER: _ClassVar[int] + REASON_CODE_FIELD_NUMBER: _ClassVar[int] + decision: Decision + reason: str + body: bytes + has_body: bool + header_mutations: _containers.RepeatedCompositeFieldContainer[HeaderMutation] + findings: _containers.RepeatedCompositeFieldContainer[Finding] + metadata: _containers.ScalarMap[str, str] + reason_code: str + def __init__(self, decision: _Optional[_Union[Decision, str]] = ..., reason: _Optional[str] = ..., body: _Optional[bytes] = ..., has_body: _Optional[bool] = ..., header_mutations: _Optional[_Iterable[_Union[HeaderMutation, _Mapping]]] = ..., findings: _Optional[_Iterable[_Union[Finding, _Mapping]]] = ..., metadata: _Optional[_Mapping[str, str]] = ..., reason_code: _Optional[str] = ...) -> None: ... diff --git a/projects/privacy-guard/src/privacy_guard/bindings/supervisor_middleware_pb2_grpc.py b/projects/privacy-guard/src/privacy_guard/bindings/supervisor_middleware_pb2_grpc.py new file mode 100644 index 0000000..a4914b3 --- /dev/null +++ b/projects/privacy-guard/src/privacy_guard/bindings/supervisor_middleware_pb2_grpc.py @@ -0,0 +1,194 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc +import warnings + +from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2 +from . import supervisor_middleware_pb2 as supervisor__middleware__pb2 + +GRPC_GENERATED_VERSION = '1.81.1' +GRPC_VERSION = grpc.__version__ +_version_not_supported = False + +try: + from grpc._utilities import first_version_is_lower + _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION) +except ImportError: + _version_not_supported = True + +if _version_not_supported: + raise RuntimeError( + f'The grpc package installed is at version {GRPC_VERSION},' + + ' but the generated code in supervisor_middleware_pb2_grpc.py depends on' + + f' grpcio>={GRPC_GENERATED_VERSION}.' + + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}' + + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.' + ) + + +class SupervisorMiddlewareStub: + """SupervisorMiddleware lets an operator-run service inspect and transform + sandbox HTTP egress before OpenShell injects credentials. + """ + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.Describe = channel.unary_unary( + '/openshell.middleware.v1.SupervisorMiddleware/Describe', + request_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, + response_deserializer=supervisor__middleware__pb2.MiddlewareManifest.FromString, + _registered_method=True) + self.ValidateConfig = channel.unary_unary( + '/openshell.middleware.v1.SupervisorMiddleware/ValidateConfig', + request_serializer=supervisor__middleware__pb2.ValidateConfigRequest.SerializeToString, + response_deserializer=supervisor__middleware__pb2.ValidateConfigResponse.FromString, + _registered_method=True) + self.EvaluateHttpRequest = channel.unary_unary( + '/openshell.middleware.v1.SupervisorMiddleware/EvaluateHttpRequest', + request_serializer=supervisor__middleware__pb2.HttpRequestEvaluation.SerializeToString, + response_deserializer=supervisor__middleware__pb2.HttpRequestResult.FromString, + _registered_method=True) + + +class SupervisorMiddlewareServicer: + """SupervisorMiddleware lets an operator-run service inspect and transform + sandbox HTTP egress before OpenShell injects credentials. + """ + + def Describe(self, request, context): + """Describe returns the service manifest and declared bindings. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def ValidateConfig(self, request, context): + """ValidateConfig checks service-specific configuration for one binding. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def EvaluateHttpRequest(self, request, context): + """EvaluateHttpRequest returns an allow, deny, or mutation decision for one + buffered HTTP request. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_SupervisorMiddlewareServicer_to_server(servicer, server): + rpc_method_handlers = { + 'Describe': grpc.unary_unary_rpc_method_handler( + servicer.Describe, + request_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString, + response_serializer=supervisor__middleware__pb2.MiddlewareManifest.SerializeToString, + ), + 'ValidateConfig': grpc.unary_unary_rpc_method_handler( + servicer.ValidateConfig, + request_deserializer=supervisor__middleware__pb2.ValidateConfigRequest.FromString, + response_serializer=supervisor__middleware__pb2.ValidateConfigResponse.SerializeToString, + ), + 'EvaluateHttpRequest': grpc.unary_unary_rpc_method_handler( + servicer.EvaluateHttpRequest, + request_deserializer=supervisor__middleware__pb2.HttpRequestEvaluation.FromString, + response_serializer=supervisor__middleware__pb2.HttpRequestResult.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'openshell.middleware.v1.SupervisorMiddleware', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + server.add_registered_method_handlers('openshell.middleware.v1.SupervisorMiddleware', rpc_method_handlers) + + + # This class is part of an EXPERIMENTAL API. +class SupervisorMiddleware: + """SupervisorMiddleware lets an operator-run service inspect and transform + sandbox HTTP egress before OpenShell injects credentials. + """ + + @staticmethod + def Describe(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/openshell.middleware.v1.SupervisorMiddleware/Describe', + google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, + supervisor__middleware__pb2.MiddlewareManifest.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def ValidateConfig(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/openshell.middleware.v1.SupervisorMiddleware/ValidateConfig', + supervisor__middleware__pb2.ValidateConfigRequest.SerializeToString, + supervisor__middleware__pb2.ValidateConfigResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def EvaluateHttpRequest(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/openshell.middleware.v1.SupervisorMiddleware/EvaluateHttpRequest', + supervisor__middleware__pb2.HttpRequestEvaluation.SerializeToString, + supervisor__middleware__pb2.HttpRequestResult.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) diff --git a/projects/privacy-guard/src/privacy_guard/cli.py b/projects/privacy-guard/src/privacy_guard/cli.py new file mode 100644 index 0000000..c0d35f2 --- /dev/null +++ b/projects/privacy-guard/src/privacy_guard/cli.py @@ -0,0 +1,323 @@ +"""Privacy Guard command-line application.""" + +from __future__ import annotations + +import importlib +import ipaddress +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Annotated + +import typer + +from privacy_guard.constants import DEFAULT_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS +from privacy_guard.engines import EntityProcessingStrategy +from privacy_guard.engines.registry import EngineRegistry, create_builtin_registry +from privacy_guard.errors import PrivacyGuardError +from privacy_guard.gateway_config import ( + MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES, + GatewayConfigError, + GatewayConfigUpdate, + default_gateway_config_path, + update_gateway_config, + validate_middleware_name, +) +from privacy_guard.logging import LoggingConfig, configure_logging, get_logger +from privacy_guard.service.server import DEFAULT_LISTEN_ADDRESS, PrivacyGuardServer +from privacy_guard.timeout import validate_timeout_seconds + +app = typer.Typer( + name="privacy-guard", + help=( + "Run Privacy Guard, configure a local OpenShell gateway, and inspect " + "installed entity-processing engines." + ), + no_args_is_help=True, + add_completion=False, +) + + +@app.callback() +def configure_cli( + context: typer.Context, + registry_factory: Annotated[ + str | None, + typer.Option( + help=( + "Load engines from a trusted Python callable, formatted as " + "module:factory. The callable must return a finalized EngineRegistry." + ), + ), + ] = None, + debug: Annotated[ + bool, + typer.Option( + "--debug", + help=( + "Log content-safe diagnostic details for startup and request handling." + ), + ), + ] = False, + debug_log_content: Annotated[ + bool, + typer.Option( + "--debug-log-content", + help=( + "DANGEROUS: log complete request and processed text, which may " + "contain secrets or personal data." + ), + ), + ] = False, +) -> None: + """Configure the command application and its engine inventory.""" + configure_logging( + LoggingConfig(level="DEBUG" if debug or debug_log_content else "INFO") + ) + context.obj = _CommandOptions( + registry=_load_registry(registry_factory), + log_request_content=debug_log_content, + ) + if debug_log_content: + _LOGGER.warning( + "privacy_guard_request_content_logging_enabled " + "complete_request_text_may_contain_secrets" + ) + + +@app.command("serve") +def serve( + context: typer.Context, + listen: Annotated[ + str, + typer.Option( + help=( + "Host and port on which Privacy Guard listens, formatted as " + "host:port. Use 0.0.0.0 when sandbox supervisors must reach it." + ), + ), + ] = DEFAULT_LISTEN_ADDRESS, + timeout_seconds: Annotated[ + float, + typer.Option( + help=( + "Maximum seconds shared by all processing stages in one request; " + f"must be greater than 0 and at most {MAX_TIMEOUT_SECONDS:g}." + ), + ), + ] = DEFAULT_TIMEOUT_SECONDS, +) -> None: + """Run Privacy Guard until the process receives a shutdown signal.""" + options = _command_options(context) + try: + validated_timeout_seconds = validate_timeout_seconds(timeout_seconds) + except ValueError as error: + raise typer.BadParameter( + str(error), + param_hint="--timeout-seconds", + ) from None + try: + PrivacyGuardServer( + options.registry, + timeout_seconds=validated_timeout_seconds, + log_request_content=options.log_request_content, + ).serve_sync(listen) + except PrivacyGuardError as error: + typer.echo(str(error), err=True) + raise typer.Exit(code=1) from None + + +@app.command("configure-gateway") +def configure_gateway( + host_ip: Annotated[ + str, + typer.Option( + help=( + "Non-loopback IPv4 address of this host that both the OpenShell " + "gateway and sandbox supervisors can reach." + ), + ), + ], + config: Annotated[ + Path | None, + typer.Option( + help=( + "Gateway TOML to update. Defaults to " + "`$OPENSHELL_GATEWAY_CONFIG` when set, otherwise `gateway.toml` " + "under `$XDG_CONFIG_HOME/openshell`." + ), + ), + ] = None, + name: Annotated[ + str, + typer.Option( + help=( + "Gateway registration name referenced by the policy's middleware " + "field. OpenShell allows " + f"1-{MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES} ASCII bytes." + ), + ), + ] = "privacy-guard", + port: Annotated[ + int, + typer.Option( + min=1, + max=65535, + help=( + "Privacy Guard port. Use the same port in `privacy-guard serve " + "--listen`." + ), + ), + ] = 50051, +) -> None: + """Add or update Privacy Guard in an OpenShell gateway TOML file.""" + try: + address = ipaddress.IPv4Address(host_ip) + except ipaddress.AddressValueError: + raise typer.BadParameter( + "Pass one IPv4 address, for example --host-ip 192.168.1.20.", + param_hint="--host-ip", + ) from None + if address.is_loopback or address.is_unspecified: + raise typer.BadParameter( + "Pass a non-loopback host IPv4 address reachable by sandbox " + "supervisors; do not use 127.0.0.1 or 0.0.0.0.", + param_hint="--host-ip", + ) + try: + validated_name = validate_middleware_name(name) + except GatewayConfigError as error: + raise typer.BadParameter( + str(error), + param_hint="--name", + ) from None + + config_path = config or default_gateway_config_path() + try: + result = update_gateway_config( + config_path, + middleware_name=validated_name, + host_ip=str(address), + port=port, + ) + except GatewayConfigError as error: + typer.echo(f"Could not configure the OpenShell gateway: {error}", err=True) + raise typer.Exit(code=1) from None + + action = { + GatewayConfigUpdate.CREATED: "Created", + GatewayConfigUpdate.ADDED: "Added the registration to", + GatewayConfigUpdate.UPDATED: "Updated", + GatewayConfigUpdate.UNCHANGED: "No changes needed in", + }[result] + typer.echo(f"{action} {config_path}") + typer.echo(f"Registered {validated_name} at http://{address}:{port}") + typer.echo( + "Next: start Privacy Guard, then restart the OpenShell gateway so it " + "loads this registration." + ) + + +@app.command("configuration-schema") +def configuration_schema(context: typer.Context) -> None: + """Print the policy configuration JSON Schema for the installed engines.""" + typer.echo( + json.dumps( + _command_options(context).registry.configuration_json_schema(), + indent=2, + ensure_ascii=False, + sort_keys=True, + ) + ) + + +@app.command("engines") +def engines(context: typer.Context) -> None: + """List installed engines, supported strategies, and their behavior.""" + for description in _command_options(context).registry.describe_engines(): + strategies = ",".join( + strategy.value + for strategy in EntityProcessingStrategy + if strategy in description.supported_strategies + ) + typer.echo( + f"{description.engine_name}\t{strategies}\t{description.description}" + ) + + +_LOGGER = get_logger(__name__) + + +@dataclass(frozen=True) +class _CommandOptions: + registry: EngineRegistry + log_request_content: bool + + +def _load_registry(factory_reference: str | None) -> EngineRegistry: + if factory_reference is None: + return create_builtin_registry() + module_name, separator, factory_name = factory_reference.partition(":") + if not separator or not module_name or not factory_name: + raise typer.BadParameter( + "Use module:factory, for example my_engines:create_registry.", + param_hint="--registry-factory", + ) + try: + module = importlib.import_module(module_name) + except Exception: + raise typer.BadParameter( + "Registry module could not be imported. Verify the module:factory " + "reference, then import the module directly with content-safe " + "diagnostics to find missing dependencies or startup failures.", + param_hint="--registry-factory", + ) from None + try: + factory = getattr(module, factory_name) + except Exception: + raise typer.BadParameter( + "Registry factory could not be resolved. Verify the module:factory " + "reference and exported callable, then access it directly with " + "content-safe diagnostics.", + param_hint="--registry-factory", + ) from None + if not callable(factory): + raise typer.BadParameter( + "Registry factory is not callable. Export a callable that returns a " + "finalized EngineRegistry.", + param_hint="--registry-factory", + ) + try: + registry = factory() + except Exception: + raise typer.BadParameter( + "Registry factory failed. Run the factory directly with content-safe " + "diagnostics and fix its startup error.", + param_hint="--registry-factory", + ) from None + if not isinstance(registry, EngineRegistry): + raise typer.BadParameter( + "Registry factory returned an invalid object. Return an EngineRegistry.", + param_hint="--registry-factory", + ) + if not registry.is_finalized: + raise typer.BadParameter( + "Registry factory returned an unfinalized registry. Call finalize() " + "before returning it.", + param_hint="--registry-factory", + ) + return registry + + +def _command_options(context: typer.Context) -> _CommandOptions: + options = context.obj + if not isinstance(options, _CommandOptions): + raise RuntimeError("Privacy Guard command context is unavailable") + return options + + +if __name__ == "__main__": + app() + + +__all__ = ["app"] diff --git a/projects/privacy-guard/src/privacy_guard/config.py b/projects/privacy-guard/src/privacy_guard/config.py new file mode 100644 index 0000000..0862880 --- /dev/null +++ b/projects/privacy-guard/src/privacy_guard/config.py @@ -0,0 +1,120 @@ +"""Strict entity-processing policy configuration. + +The concrete model accepted at the policy boundary is finalized by +``EngineRegistry``. Its stage ``config`` field is a Pydantic discriminated +union containing the exact config model registered by every engine. +""" + +from __future__ import annotations + +from enum import StrEnum +from typing import Generic, Self, TypeVar + +from pydantic import ( + Field, + field_validator, + model_validator, +) + +from privacy_guard.base import StrictDomainModel +from privacy_guard.constants import MAX_ENTITY_PROCESSING_STAGES +from privacy_guard.engines import EngineConfig +from privacy_guard.string_validators import ( + BoundedMetadataString, + validate_scalar_string, +) + + +class PolicyAction(StrEnum): + """User-facing disposition applied after all configured stages run.""" + + DETECT = "detect" + BLOCK = "block" + REPLACE = "replace" + + +class OnDetection(StrictDomainModel): + """Required policy disposition for detected entities.""" + + action: PolicyAction + + @field_validator("action", mode="before") + @classmethod + def _parse_action(cls, value: object) -> PolicyAction: + if isinstance(value, PolicyAction): + return value + return PolicyAction(validate_scalar_string(value)) + + +_EngineConfigT = TypeVar( + "_EngineConfigT", + bound=EngineConfig, +) + + +class EntityProcessingStage( + StrictDomainModel, + Generic[_EngineConfigT], +): + """One ordered invocation of an engine with an optional diagnostic name.""" + + name: BoundedMetadataString | None = None + config: _EngineConfigT = Field(repr=False) + + def diagnostic_name(self, stage_number: int) -> str: + """Return the explicit name or a deterministic one-based source label.""" + if self.name is not None: + return self.name + if isinstance(stage_number, bool) or stage_number < 1: + raise ValueError("stage number must be a positive integer") + engine = getattr(self.config, "engine", None) + if not isinstance(engine, str): + raise ValueError("stage config has no engine discriminator") + return f"{engine}[{stage_number}]" + + +class EntityProcessingStages( + StrictDomainModel, + Generic[_EngineConfigT], +): + """The ordered entity-processing stages for one policy.""" + + stages: tuple[EntityProcessingStage[_EngineConfigT], ...] = Field(repr=False) + + @field_validator("stages", mode="before") + @classmethod + def _parse_stages(cls, value: object) -> object: + if not isinstance(value, list | tuple) or not value: + raise ValueError("stages must be a non-empty list") + if len(value) > MAX_ENTITY_PROCESSING_STAGES: + raise ValueError("policy has too many entity-processing stages") + return tuple(value) + + @model_validator(mode="after") + def _diagnostic_names_are_unique(self) -> Self: + names = [ + stage.diagnostic_name(index) + for index, stage in enumerate(self.stages, start=1) + ] + if len(names) != len(set(names)): + raise ValueError("stage diagnostic names must be unique") + return self + + +class PrivacyGuardConfig( + StrictDomainModel, + Generic[_EngineConfigT], +): + """Complete validated Privacy Guard behavior for one OpenShell policy.""" + + entity_processing: EntityProcessingStages[_EngineConfigT] = Field(repr=False) + on_detection: OnDetection = Field(repr=False) + + +__all__ = [ + "EntityProcessingStage", + "EntityProcessingStages", + "OnDetection", + "PolicyAction", + "PrivacyGuardConfig", +] diff --git a/projects/privacy-guard/src/privacy_guard/constants.py b/projects/privacy-guard/src/privacy_guard/constants.py new file mode 100644 index 0000000..4fbda0c --- /dev/null +++ b/projects/privacy-guard/src/privacy_guard/constants.py @@ -0,0 +1,69 @@ +"""Package-wide Privacy Guard constants and operational limits. + +Keep this module dependency-free within the package: it must not import from +``privacy_guard``. +""" + +from __future__ import annotations + +import re +from importlib.metadata import version + +# Configurable processing timeout. +DEFAULT_TIMEOUT_SECONDS = 1.0 +MAX_TIMEOUT_SECONDS = 30.0 + +# Middleware identity and stable response values. +SERVICE_NAME = "privacy-guard" +SERVICE_VERSION = version("privacy-guard") +BLOCK_REASON = "Privacy Guard blocked the request" +BLOCK_REASON_CODE = "privacy_guard_blocked" +LIMIT_REASON = ( + "Privacy Guard exceeded a processing safety limit. Check Privacy Guard logs " + "for the limit kind. Reduce the request or replacement size, simplify the " + "configured stages and rules, or increase the processing timeout with " + "--timeout-seconds or " + "PrivacyGuardServer(timeout_seconds=...) to at most " + f"{MAX_TIMEOUT_SECONDS:g} seconds. If increasing it, give OpenShell's " + "middleware timeout additional headroom for queueing and configuration " + "preparation, then retry." +) +LIMIT_REASON_CODE = "privacy_guard_limit_exceeded" +# Text input limits. +MAX_BODY_BYTES = 4 * 1024 * 1024 + +# Engine and result limits. +MAX_DETECTIONS_PER_STAGE = 256 +MAX_DETECTIONS_PER_REQUEST = 4096 +MAX_DIAGNOSTIC_TEXT_BYTES = 1024 +MAX_FINDING_METADATA_ENTRIES = 32 +MAX_PROTO_FINDING_GROUPS = 32 +MAX_PROTO_FINDING_BYTES = 4 * 1024 + +# Engine configuration and regex execution limits. +MAX_ENTITY_PROCESSING_STAGES = 10 +MAX_REGEX_NAME_BYTES = 128 +MAX_REGEX_ENTITIES_PER_CATALOG = 2_000 +MAX_REGEX_RULES_PER_CATALOG = 10_000 +MAX_REGEX_PATTERN_BYTES = 16 * 1024 +MAX_REGEX_CATALOG_FILE_BYTES = 16 * 1024 * 1024 +MAX_REGEX_CATALOG_PATH_BYTES = 1024 + +# Prepared-state cache budget. The entry-count cap remains a secondary guard. +MAX_REGEX_COMPILED_CACHE_WEIGHT_BYTES = 32 * 1024 * 1024 +REGEX_COMPILED_RULE_WEIGHT_BYTES = 4 * 1024 + +# Service concurrency and transport limits. +MAX_CONCURRENT_PROCESSING = 4 +MAX_CONCURRENT_RPCS = 16 +# Mirrored from the encoded OpenShell v0.0.90 middleware contract. +MAX_PROTO_CONTEXT_BYTES = 4 * 1024 +MAX_PROTO_CONFIG_BYTES = 64 * 1024 +MAX_PROTO_TARGET_BYTES = 32 * 1024 +MAX_PROTO_HEADERS = 128 +MAX_PROTO_HEADERS_BYTES = 64 * 1024 +PROTOBUF_ENVELOPE_ALLOWANCE_BYTES = 1024 * 1024 +MAX_RECEIVE_MESSAGE_BYTES = MAX_BODY_BYTES + PROTOBUF_ENVELOPE_ALLOWANCE_BYTES + +# Protocol validation values. +REASON_CODE_PATTERN = re.compile(r"[a-z][a-z0-9_]{0,63}\Z") diff --git a/projects/privacy-guard/src/privacy_guard/engines/__init__.py b/projects/privacy-guard/src/privacy_guard/engines/__init__.py new file mode 100644 index 0000000..59a843d --- /dev/null +++ b/projects/privacy-guard/src/privacy_guard/engines/__init__.py @@ -0,0 +1,53 @@ +"""Supported entity-processing extension and built-in regex engine surface.""" + +from __future__ import annotations + +from privacy_guard.engines.base import ( + BoundedMetadata, + ConfidenceLevel, + EngineConfig, + EngineResources, + EntityDetection, + EntityName, + EntityProcessingEngine, + EntityProcessingStrategy, + TextProcessingResult, +) +from privacy_guard.engines.regex import ( + RegexEngine, + RegexEngineConfig, + RegexEntity, + RegexPatternCatalog, + RegexReplacement, + RegexRule, +) +from privacy_guard.errors import ( + EngineConfigurationError, + EngineContractError, + EngineExecutionError, + EngineLimitExceededError, + EntityProcessingError, +) + +__all__ = [ + "BoundedMetadata", + "ConfidenceLevel", + "EngineConfig", + "EngineConfigurationError", + "EngineContractError", + "EngineExecutionError", + "EngineLimitExceededError", + "EngineResources", + "EntityDetection", + "EntityName", + "EntityProcessingEngine", + "EntityProcessingError", + "EntityProcessingStrategy", + "RegexEngine", + "RegexEngineConfig", + "RegexEntity", + "RegexPatternCatalog", + "RegexReplacement", + "RegexRule", + "TextProcessingResult", +] diff --git a/projects/privacy-guard/src/privacy_guard/engines/base.py b/projects/privacy-guard/src/privacy_guard/engines/base.py new file mode 100644 index 0000000..c6d60bf --- /dev/null +++ b/projects/privacy-guard/src/privacy_guard/engines/base.py @@ -0,0 +1,367 @@ +"""Core entity-processing engine extension contract.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Iterable, Mapping +from enum import StrEnum +from itertools import islice +from types import MappingProxyType +from typing import ( + Annotated, + ClassVar, + Generic, + Self, + TypeAlias, + final, + get_args, + get_origin, +) + +from pydantic import ( + BeforeValidator, + Field, + ValidationError, + field_validator, + model_validator, +) +from typing_extensions import TypeVar + +from privacy_guard.base import StrictDomainModel +from privacy_guard.constants import ( + MAX_BODY_BYTES, + MAX_DETECTIONS_PER_STAGE, + MAX_FINDING_METADATA_ENTRIES, +) +from privacy_guard.errors import ( + EngineConfigurationError, + EngineContractError, + EngineLimitExceededError, +) +from privacy_guard.string_validators import ( + ScalarString, + validate_bounded_metadata_string, + validate_scalar_string, +) +from privacy_guard.timeout import Timeout + + +class EntityProcessingStrategy(StrEnum): + """Select whether one engine invocation detects or replaces entities.""" + + DETECT = "detect" + REPLACE = "replace" + + +class ConfidenceLevel(StrEnum): + """Categorical certainty reported by an entity-processing engine.""" + + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + + +EntityName = Annotated[str, BeforeValidator(validate_bounded_metadata_string)] +MetadataString = Annotated[str, BeforeValidator(validate_bounded_metadata_string)] +BoundedMetadata: TypeAlias = Mapping[MetadataString, MetadataString] + + +class EntityDetection(StrictDomainModel): + """One sensitive entity occurrence in the engine's input text.""" + + entity: EntityName + start: int = Field(ge=0) + end: int + confidence: ConfidenceLevel | None = None + metadata: BoundedMetadata = Field(default_factory=dict, repr=False) + + @field_validator("confidence", mode="before") + @classmethod + def _parse_confidence(cls, value: object) -> object: + if isinstance(value, str): + return ConfidenceLevel(validate_scalar_string(value)) + return value + + @field_validator("metadata") + @classmethod + def _copy_bounded_metadata(cls, value: Mapping[str, str]) -> Mapping[str, str]: + if len(value) > MAX_FINDING_METADATA_ENTRIES: + raise ValueError("detection metadata has too many entries") + copied: dict[str, str] = {} + for key, item in value.items(): + copied[validate_bounded_metadata_string(key)] = ( + validate_bounded_metadata_string(item) + ) + return MappingProxyType(copied) + + @model_validator(mode="after") + def _span_is_non_empty(self) -> EntityDetection: + if self.end <= self.start: + raise ValueError("detection span must be non-empty") + return self + + +class TextProcessingResult(StrictDomainModel): + """The authoritative text and detections returned by one engine run.""" + + text: ScalarString = Field(repr=False) + detections: tuple[EntityDetection, ...] + + @field_validator("detections", mode="before") + @classmethod + def _detections_are_a_tuple(cls, value: object) -> object: + if not isinstance(value, tuple): + raise ValueError("detections must be a tuple") + return value + + @classmethod + def from_detections( + cls, + *, + text: str, + detections: Iterable[EntityDetection], + ) -> Self: + """Safely materialize a lazy stream; ``run()`` still validates the result.""" + bounded = tuple(islice(detections, MAX_DETECTIONS_PER_STAGE + 1)) + if len(bounded) > MAX_DETECTIONS_PER_STAGE: + raise EngineLimitExceededError("engine returned too many detections") + return cls(text=text, detections=bounded) + + +class EngineConfig(StrictDomainModel): + """Nominal base for an engine's exact policy configuration.""" + + +class EngineResources: + """Optional operator-owned runtime dependencies shared by engine instances. + + Resource objects contain initialized operational dependencies such as model + clients, SDK adapters, endpoints, or credential providers. They must not + contain policy behavior or mutable per-request state, and everything they + expose to an engine must be safe for concurrent use. + """ + + __slots__ = () + + +_ConfigT = TypeVar("_ConfigT", bound=EngineConfig) +_ResourcesT = TypeVar( + "_ResourcesT", + bound=EngineResources | None, + default=None, +) + + +class EntityProcessingEngine(ABC, Generic[_ConfigT, _ResourcesT]): + """Nominal, typed extension point for processing one text string.""" + + supported_strategies: ClassVar[frozenset[EntityProcessingStrategy]] + + @final + def __init__( + self, + config: _ConfigT, + resources: _ResourcesT, + ) -> None: + """Validate typed configuration/resources and initialize reusable state.""" + self.validate_config(config, resources) + self.__config = config + self.__resources = resources + self._initialize() + + @classmethod + def validate_config( + cls, + config: _ConfigT, + resources: _ResourcesT, + ) -> None: + """Purely validate one exact config and its registered resources.""" + cls._validate_class_contract() + config_type = cls.get_config_type() + try: + if not isinstance(config, config_type): + raise ValueError + config_type.model_validate(config) + except (ValidationError, ValueError): + raise EngineConfigurationError("engine configuration is invalid") from None + resources_type = cls.get_resources_type() + if not _is_valid_resources(resources, resources_type): + raise EngineConfigurationError("engine resources are invalid") + cls._validate_config(config, resources) + + @classmethod + def validate_run_config( + cls, + config: _ConfigT, + resources: _ResourcesT, + *, + strategy: EntityProcessingStrategy, + ) -> None: + """Validate that one config can execute the requested strategy.""" + if not isinstance(strategy, EntityProcessingStrategy): + raise EngineConfigurationError("engine processing strategy is invalid") + cls.validate_config(config, resources) + if strategy not in cls.supported_strategies: + raise EngineConfigurationError( + "engine does not support the requested strategy" + ) + cls._validate_run_config(config, resources, strategy=strategy) + + @classmethod + def get_config_type(cls) -> type[EngineConfig]: + """Return the concrete ``EngineConfig`` type argument.""" + config_type, _ = _declared_engine_types(cls) + return config_type + + @classmethod + def get_resources_type(cls) -> object: + """Return the concrete runtime-resources generic argument.""" + _, resources_type = _declared_engine_types(cls) + return resources_type + + @property + def config(self) -> _ConfigT: + """Return the immutable, concrete engine configuration.""" + return self.__config + + @property + def resources(self) -> _ResourcesT: + """Return the validated, injected runtime resources.""" + return self.__resources + + @final + def run( + self, + text: str, + *, + strategy: EntityProcessingStrategy, + timeout: Timeout, + ) -> TextProcessingResult: + """Process one text value and validate the complete collaborator result.""" + try: + validated_text = validate_scalar_string(text) + except ValueError: + raise EngineContractError("engine input text is invalid") from None + if not isinstance(strategy, EntityProcessingStrategy): + raise EngineContractError("engine processing strategy is invalid") + if not isinstance(timeout, Timeout): + raise EngineContractError("engine timeout is invalid") + if strategy not in self.supported_strategies: + raise EngineContractError("engine does not support the requested strategy") + timeout.raise_if_expired() + result: object = self._run( + validated_text, + strategy=strategy, + timeout=timeout, + ) + timeout.raise_if_expired() + return _validate_result(validated_text, result, strategy=strategy) + + @classmethod + def _validate_config( + cls, + config: _ConfigT, + resources: _ResourcesT, + ) -> None: + """Optionally validate resource-backed config without side effects.""" + + @classmethod + def _validate_run_config( + cls, + config: _ConfigT, + resources: _ResourcesT, + *, + strategy: EntityProcessingStrategy, + ) -> None: + """Optionally validate requirements specific to one run strategy.""" + + @classmethod + def _validate_class_contract(cls) -> None: + supported_strategies = getattr(cls, "supported_strategies", None) + if ( + not isinstance(supported_strategies, frozenset) + or not supported_strategies + or any( + not isinstance(strategy, EntityProcessingStrategy) + for strategy in supported_strategies + ) + ): + raise EngineConfigurationError("engine supported strategies are invalid") + cls.get_config_type() + cls.get_resources_type() + + def _initialize(self) -> None: + """Optionally initialize reusable state from config and resources.""" + + @abstractmethod + def _run( + self, + text: str, + *, + strategy: EntityProcessingStrategy, + timeout: Timeout, + ) -> TextProcessingResult: + """Return processed text and every detected entity occurrence.""" + raise NotImplementedError + + +def _declared_engine_types( + engine_type: type[object], +) -> tuple[type[EngineConfig], object]: + for candidate in engine_type.__mro__: + for base in getattr(candidate, "__orig_bases__", ()): + if get_origin(base) is not EntityProcessingEngine: + continue + arguments = get_args(base) + if len(arguments) != 2: + break + config_type, resources_type = arguments + if isinstance(config_type, type) and issubclass(config_type, EngineConfig): + return config_type, resources_type + raise EngineConfigurationError( + "engine must declare concrete configuration and resource types" + ) + + +def _is_valid_resources(resources: object, resources_type: object) -> bool: + if resources_type in (None, type(None)): + return resources is None + origin = get_origin(resources_type) + if origin is not None: + resources_type = origin + return isinstance(resources_type, type) and isinstance(resources, resources_type) + + +def _validate_result( + input_text: str, + result: object, + *, + strategy: EntityProcessingStrategy, +) -> TextProcessingResult: + if not isinstance(result, TextProcessingResult): + raise EngineContractError("engine output is invalid") + if len(result.detections) > MAX_DETECTIONS_PER_STAGE: + raise EngineLimitExceededError("engine returned too many detections") + if len(result.text.encode("utf-8")) > MAX_BODY_BYTES: + raise EngineLimitExceededError("engine output text exceeds the size limit") + for detection in result.detections: + if detection.end > len(input_text): + raise EngineContractError("engine detection span is invalid") + if strategy is EntityProcessingStrategy.DETECT and result.text != input_text: + raise EngineContractError("detection-only engine output changed text") + if result.text != input_text and not result.detections: + raise EngineContractError("engine changed text without a detection") + return result + + +__all__ = [ + "BoundedMetadata", + "ConfidenceLevel", + "EngineConfig", + "EngineResources", + "EntityDetection", + "EntityName", + "EntityProcessingEngine", + "EntityProcessingStrategy", + "TextProcessingResult", +] diff --git a/projects/privacy-guard/src/privacy_guard/engines/regex.py b/projects/privacy-guard/src/privacy_guard/engines/regex.py new file mode 100644 index 0000000..4b0323b --- /dev/null +++ b/projects/privacy-guard/src/privacy_guard/engines/regex.py @@ -0,0 +1,726 @@ +"""Bounded regular-expression entity detection and replacement.""" + +from __future__ import annotations + +import json +import os +from collections import OrderedDict +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from stat import S_ISREG +from string import Formatter +from threading import RLock +from typing import Literal, Protocol, Self + +import regex +import yaml +from pydantic import Field, field_validator, model_validator +from yaml.constructor import ConstructorError +from yaml.events import AliasEvent +from yaml.nodes import MappingNode +from yaml.resolver import BaseResolver + +from privacy_guard.base import StrictDomainModel +from privacy_guard.constants import ( + MAX_BODY_BYTES, + MAX_DETECTIONS_PER_STAGE, + MAX_DIAGNOSTIC_TEXT_BYTES, + MAX_REGEX_CATALOG_FILE_BYTES, + MAX_REGEX_CATALOG_PATH_BYTES, + MAX_REGEX_COMPILED_CACHE_WEIGHT_BYTES, + MAX_REGEX_ENTITIES_PER_CATALOG, + MAX_REGEX_NAME_BYTES, + MAX_REGEX_PATTERN_BYTES, + MAX_REGEX_RULES_PER_CATALOG, + REGEX_COMPILED_RULE_WEIGHT_BYTES, +) +from privacy_guard.engines.base import ( + ConfidenceLevel, + EngineConfig, + EntityDetection, + EntityProcessingEngine, + EntityProcessingStrategy, + TextProcessingResult, +) +from privacy_guard.errors import ( + EngineConfigurationError, + EngineContractError, + EngineLimitExceededError, +) +from privacy_guard.logging import get_logger +from privacy_guard.string_validators import ScalarString, validate_scalar_string +from privacy_guard.timeout import Timeout + + +class RegexRule(StrictDomainModel): + """One regex pattern with its diagnostic identity, confidence, and flags.""" + + name: str | None = None + pattern: ScalarString = Field(repr=False) + confidence: ConfidenceLevel + ignore_case: bool = False + multiline: bool = False + dot_all: bool = False + ascii: bool = False + + @field_validator("name") + @classmethod + def _name_is_safe(cls, value: str | None) -> str | None: + return None if value is None else _validate_name(value) + + @field_validator("pattern") + @classmethod + def _pattern_is_bounded(cls, value: str) -> str: + if not value: + raise ValueError("pattern must be non-empty") + if len(value.encode("utf-8")) > MAX_REGEX_PATTERN_BYTES: + raise ValueError("pattern exceeds the size limit") + return value + + @field_validator("confidence", mode="before") + @classmethod + def _parse_confidence(cls, value: object) -> ConfidenceLevel: + if not isinstance(value, str): + raise ValueError("confidence must be a string") + return ConfidenceLevel(validate_scalar_string(value)) + + +class RegexEntity(StrictDomainModel): + """One entity name and its ordered, non-empty regex rules.""" + + name: str + rules: tuple[RegexRule, ...] = Field(repr=False) + + @field_validator("name") + @classmethod + def _name_is_safe(cls, value: str) -> str: + return _validate_name(value) + + @field_validator("rules", mode="before") + @classmethod + def _rules_are_non_empty(cls, value: object) -> object: + if not isinstance(value, list | tuple) or not value: + raise ValueError("rules must be a non-empty list") + return tuple(value) + + @model_validator(mode="after") + def _supplied_rule_names_are_unique(self) -> Self: + supplied_names = [rule.name for rule in self.rules if rule.name is not None] + if len(supplied_names) != len(set(supplied_names)): + raise ValueError("supplied rule names must be unique within an entity") + return self + + +class RegexPatternCatalog(StrictDomainModel): + """The complete ordered entity catalog for one RegexEngine stage.""" + + entities: tuple[RegexEntity, ...] = Field(repr=False) + + @field_validator("entities", mode="before") + @classmethod + def _entities_are_non_empty(cls, value: object) -> object: + if not isinstance(value, list | tuple) or not value: + raise ValueError("entities must be a non-empty list") + return tuple(value) + + @model_validator(mode="after") + def _catalog_is_bounded_and_unambiguous(self) -> Self: + names = [entity.name for entity in self.entities] + if len(names) != len(set(names)): + raise ValueError("entity names must be unique") + if len(self.entities) > MAX_REGEX_ENTITIES_PER_CATALOG: + raise ValueError("entity catalog exceeds the size limit") + if ( + sum(len(entity.rules) for entity in self.entities) + > MAX_REGEX_RULES_PER_CATALOG + ): + raise ValueError("rule catalog exceeds the size limit") + return self + + +class RegexReplacement(StrictDomainModel): + """A constrained template replacement recipe.""" + + strategy: Literal["template"] = "template" + template: ScalarString = Field(default="[{entity}]", repr=False) + + @field_validator("template") + @classmethod + def _template_is_safe_and_bounded(cls, value: str) -> str: + if len(value.encode("utf-8")) > MAX_DIAGNOSTIC_TEXT_BYTES: + raise ValueError("replacement template exceeds the size limit") + try: + for _, field_name, format_spec, conversion in Formatter().parse(value): + if field_name is not None and field_name != "entity": + raise ValueError + if format_spec or conversion is not None: + raise ValueError + except ValueError: + raise ValueError("replacement template syntax is invalid") from None + return value + + +class RegexEngineConfig(EngineConfig): + """Exact policy configuration owned by ``RegexEngine``.""" + + engine: Literal["regex"] = "regex" + pattern_catalog: RegexPatternCatalog = Field( + repr=False, + description=( + "Complete structured catalog or relative path to a complete YAML catalog." + ), + ) + replacement: RegexReplacement | None = None + + @field_validator( + "pattern_catalog", + mode="before", + json_schema_input_type=RegexPatternCatalog | str, + ) + @classmethod + def _load_pattern_catalog( + cls, + value: object, + ) -> object: + del cls + if isinstance(value, str): + return _load_pattern_catalog_file(value) + return value + + @model_validator(mode="after") + def _rules_are_valid(self) -> Self: + try: + _compile_pattern_catalog(self.pattern_catalog) + except (RecursionError, ValueError, regex.error): + raise ValueError("regex pattern catalog is invalid") from None + return self + + +class RegexEngine(EntityProcessingEngine[RegexEngineConfig]): + """Detect every regex match, including matches that share input characters.""" + + supported_strategies = frozenset( + { + EntityProcessingStrategy.DETECT, + EntityProcessingStrategy.REPLACE, + } + ) + + @classmethod + def _validate_run_config( + cls, + config: RegexEngineConfig, + resources: None, + *, + strategy: EntityProcessingStrategy, + ) -> None: + del cls, resources + if strategy is EntityProcessingStrategy.REPLACE and config.replacement is None: + raise EngineConfigurationError( + "regex replacement configuration is required" + ) + + def _initialize(self) -> None: + try: + self._rules = _compile_pattern_catalog(self.config.pattern_catalog) + except (RecursionError, ValueError, regex.error): + raise EngineConfigurationError( + "regex engine configuration is invalid" + ) from None + + def _run( + self, + text: str, + *, + strategy: EntityProcessingStrategy, + timeout: Timeout, + ) -> TextProcessingResult: + detections_with_identity: list[tuple[EntityDetection, str]] = [] + for rule in self._rules: + next_position = 0 + while next_position <= len(text): + with timeout.enforce(): + match = rule.compiled.search( + text, + next_position, + timeout=timeout.remaining_seconds(), + ) + if match is None: + break + start, end = match.span() + if start == end: + raise EngineConfigurationError( + "regex engine configuration is invalid" + ) + if match.span(rule.marker) != (end, end): + raise EngineConfigurationError( + "regex engine configuration is invalid" + ) + detection = EntityDetection( + entity=rule.entity, + start=start, + end=end, + confidence=rule.confidence, + metadata={_RULE_METADATA_KEY: rule.rule_identity}, + ) + detections_with_identity.append((detection, rule.rule_identity)) + if len(detections_with_identity) > MAX_DETECTIONS_PER_STAGE: + raise EngineLimitExceededError( + "regex detection count exceeds the limit" + ) + next_position = start + 1 + + detections_with_identity.sort( + key=lambda item: ( + item[0].start, + item[0].end, + item[0].entity, + item[1], + ) + ) + detections = tuple(item[0] for item in detections_with_identity) + output_text = text + if strategy is EntityProcessingStrategy.REPLACE and detections: + replacement = self.config.replacement + if replacement is None: + raise EngineConfigurationError( + "regex replacement configuration is required" + ) + winners = _resolve_overlaps(detections_with_identity) + output_text = _render_bounded_replacement( + text, + winners, + replacement.template, + ) + return TextProcessingResult(text=output_text, detections=detections) + + +@dataclass(frozen=True) +class _CompiledRule: + entity: str + rule_identity: str + confidence: ConfidenceLevel + marker: str + compiled: _CompiledPattern + + +class _RegexMatch(Protocol): + def span(self, group: int | str = 0) -> tuple[int, int]: + """Return the matched span for a numbered or named group.""" + ... + + +class _CompiledPattern(Protocol): + @property + def groupindex(self) -> Mapping[str, int]: + """Return the pattern's named capture groups.""" + ... + + def search( + self, + string: str, + pos: int = 0, + *, + timeout: float | None = None, + ) -> _RegexMatch | None: + """Search from a code-point offset with a bounded timeout.""" + ... + + +class _StrictCatalogLoader(yaml.SafeLoader): + """Safe YAML loader that rejects aliases and duplicate mapping keys.""" + + def compose_node( + self, + parent: object, + index: object, + ) -> yaml.Node: + if self.check_event(AliasEvent): + raise ConstructorError( + None, + None, + "YAML aliases are not supported", + self.peek_event().start_mark, + ) + return super().compose_node(parent, index) + + +def _construct_unique_mapping( + loader: _StrictCatalogLoader, + node: MappingNode, + deep: bool = False, +) -> dict[object, object]: + loader.flatten_mapping(node) + mapping: dict[object, object] = {} + for key_node, value_node in node.value: + key = loader.construct_object(key_node, deep=deep) + try: + duplicate = key in mapping + except TypeError: + raise ConstructorError( + "while constructing a mapping", + node.start_mark, + "found an unhashable key", + key_node.start_mark, + ) from None + if duplicate: + raise ConstructorError( + "while constructing a mapping", + node.start_mark, + "found a duplicate key", + key_node.start_mark, + ) + mapping[key] = loader.construct_object(value_node, deep=deep) + return mapping + + +_StrictCatalogLoader.add_constructor( + BaseResolver.DEFAULT_MAPPING_TAG, + _construct_unique_mapping, +) + + +def _load_pattern_catalog_file(value: str) -> RegexPatternCatalog: + descriptor: int | None = None + try: + path_text = validate_scalar_string(value) + if ( + not path_text + or len(path_text.encode("utf-8")) > MAX_REGEX_CATALOG_PATH_BYTES + ): + raise ValueError + relative_path = Path(path_text) + if ( + relative_path.is_absolute() + or relative_path.suffix.lower() not in {".yaml", ".yml"} + or ".." in relative_path.parts + ): + raise ValueError + + descriptor = _open_pattern_catalog_file(relative_path) + metadata = os.fstat(descriptor) + if ( + not S_ISREG(metadata.st_mode) + or metadata.st_size > MAX_REGEX_CATALOG_FILE_BYTES + ): + raise ValueError + return _read_pattern_catalog_file( + descriptor, + metadata.st_size, + ) + except ( + OSError, + RecursionError, + UnicodeError, + ValueError, + yaml.YAMLError, + ): + raise ValueError("regex pattern catalog file is invalid") from None + finally: + if descriptor is not None: + os.close(descriptor) + + +def _open_pattern_catalog_file(relative_path: Path) -> int: + directory_flags = os.O_RDONLY | os.O_CLOEXEC | os.O_DIRECTORY | os.O_NOFOLLOW + file_flags = os.O_RDONLY | os.O_CLOEXEC | os.O_NONBLOCK | os.O_NOFOLLOW + directory = os.open(".", directory_flags) + try: + for part in relative_path.parts[:-1]: + child = os.open(part, directory_flags, dir_fd=directory) + os.close(directory) + directory = child + return os.open(relative_path.parts[-1], file_flags, dir_fd=directory) + finally: + os.close(directory) + + +def _read_pattern_catalog_file( + descriptor: int, + expected_size: int, +) -> RegexPatternCatalog: + contents = _read_bounded_file(descriptor) + if len(contents) != expected_size or len(contents) > MAX_REGEX_CATALOG_FILE_BYTES: + raise ValueError + values = yaml.load( + contents.decode("utf-8", errors="strict"), + Loader=_StrictCatalogLoader, + ) + return RegexPatternCatalog.model_validate(values) + + +def _read_bounded_file(descriptor: int) -> bytes: + contents = bytearray() + while len(contents) <= MAX_REGEX_CATALOG_FILE_BYTES: + chunk = os.read( + descriptor, + min(64 * 1024, MAX_REGEX_CATALOG_FILE_BYTES + 1 - len(contents)), + ) + if not chunk: + break + contents.extend(chunk) + return bytes(contents) + + +def _validate_name(value: str) -> str: + if ( + not isinstance(value, str) + or _NAME_PATTERN.fullmatch(value) is None + or len(value.encode("ascii")) > MAX_REGEX_NAME_BYTES + ): + raise ValueError("name is invalid") + return value + + +def _compile_pattern_catalog( + catalog: RegexPatternCatalog, +) -> tuple[_CompiledRule, ...]: + with _COMPILED_PATTERN_CACHE_LOCK: + cached = _COMPILED_PATTERN_CACHE.get(catalog) + if cached is not None: + _COMPILED_PATTERN_CACHE.move_to_end(catalog) + return cached[0] + + rules = tuple( + _compile_rule( + entity, + rule, + catalog_index=global_index, + entity_rule_index=rule_index, + ) + for global_index, (entity, rule_index, rule) in enumerate( + _iter_catalog_rules(catalog) + ) + ) + weight_bytes = _compiled_pattern_weight(catalog, len(rules)) + if weight_bytes > MAX_REGEX_COMPILED_CACHE_WEIGHT_BYTES: + _LOGGER.debug( + "privacy_guard_cache_skip cache=regex_compiled " + "weight_bytes=%d budget_bytes=%d", + weight_bytes, + MAX_REGEX_COMPILED_CACHE_WEIGHT_BYTES, + ) + return rules + + evicted_entries = 0 + evicted_weight_bytes = 0 + with _COMPILED_PATTERN_CACHE_LOCK: + cached = _COMPILED_PATTERN_CACHE.get(catalog) + if cached is not None: + _COMPILED_PATTERN_CACHE.move_to_end(catalog) + return cached[0] + + global _COMPILED_PATTERN_CACHE_WEIGHT_BYTES + _COMPILED_PATTERN_CACHE[catalog] = (rules, weight_bytes) + _COMPILED_PATTERN_CACHE_WEIGHT_BYTES += weight_bytes + while ( + len(_COMPILED_PATTERN_CACHE) > _MAX_CACHED_COMPILED_CATALOGS + or _COMPILED_PATTERN_CACHE_WEIGHT_BYTES + > MAX_REGEX_COMPILED_CACHE_WEIGHT_BYTES + ): + _, (_, evicted_weight) = _COMPILED_PATTERN_CACHE.popitem(last=False) + _COMPILED_PATTERN_CACHE_WEIGHT_BYTES -= evicted_weight + evicted_weight_bytes += evicted_weight + evicted_entries += 1 + if evicted_entries: + _LOGGER.debug( + "privacy_guard_cache_eviction cache=regex_compiled " + "entries=%d weight_bytes=%d", + evicted_entries, + evicted_weight_bytes, + ) + return rules + + +def _compiled_pattern_weight( + catalog: RegexPatternCatalog, + rule_count: int, +) -> int: + catalog_bytes = len( + json.dumps( + catalog.model_dump(mode="json"), + allow_nan=False, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + ) + return catalog_bytes + rule_count * REGEX_COMPILED_RULE_WEIGHT_BYTES + + +def _clear_compiled_pattern_cache() -> None: + global _COMPILED_PATTERN_CACHE_WEIGHT_BYTES + with _COMPILED_PATTERN_CACHE_LOCK: + _COMPILED_PATTERN_CACHE.clear() + _COMPILED_PATTERN_CACHE_WEIGHT_BYTES = 0 + + +def _compile_rule( + entity: RegexEntity, + rule: RegexRule, + catalog_index: int, + entity_rule_index: int, +) -> _CompiledRule: + flags = 0 + if rule.ignore_case: + flags |= regex.IGNORECASE + if rule.multiline: + flags |= regex.MULTILINE + if rule.dot_all: + flags |= regex.DOTALL + if rule.ascii: + flags |= regex.ASCII + if _contains_inline_flags(rule.pattern): + raise ValueError("inline flags are unsupported") + unmarked = regex.compile(rule.pattern, flags) + if unmarked.groupindex: + raise ValueError("named groups are reserved") + if unmarked.search("") is not None: + raise ValueError("pattern must not match empty input") + marker = f"_pg_rule_{catalog_index:06d}" + compiled = regex.compile(f"(?:{rule.pattern})(?P<{marker}>)", flags) + if marker not in compiled.groupindex: + raise ValueError("internal marker is missing") + rule_identity = rule.name or f"{entity.name}.rules[{entity_rule_index}]" + return _CompiledRule( + entity=entity.name, + rule_identity=rule_identity, + confidence=rule.confidence, + marker=marker, + compiled=compiled, + ) + + +def _iter_catalog_rules( + catalog: RegexPatternCatalog, +) -> tuple[tuple[RegexEntity, int, RegexRule], ...]: + return tuple( + (entity, rule_index, rule) + for entity in catalog.entities + for rule_index, rule in enumerate(entity.rules) + ) + + +def _contains_inline_flags(pattern: str) -> bool: + escaped = False + in_character_class = False + index = 0 + while index < len(pattern): + character = pattern[index] + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == "[": + in_character_class = True + elif character == "]" and in_character_class: + in_character_class = False + elif not in_character_class and pattern.startswith("(?", index): + suffix = pattern[index + 2 :] + if _INLINE_FLAG_PATTERN.match(suffix) is not None: + return True + index += 1 + return False + + +def _resolve_overlaps( + detections: list[tuple[EntityDetection, str]], +) -> tuple[EntityDetection, ...]: + winners: list[EntityDetection] = [] + ranked = sorted( + detections, + key=lambda item: ( + -_categorical_confidence_rank(item[0].confidence), + -(item[0].end - item[0].start), + item[0].start, + item[0].end, + item[0].entity, + item[1], + ), + ) + for candidate, _ in ranked: + if all( + candidate.end <= winner.start or candidate.start >= winner.end + for winner in winners + ): + winners.append(candidate) + return tuple( + sorted( + winners, + key=lambda item: (item.start, item.end, item.entity), + ) + ) + + +def _categorical_confidence_rank(confidence: object) -> int: + if not isinstance(confidence, ConfidenceLevel): + raise EngineContractError("regex detection confidence is invalid") + return _CONFIDENCE_RANK[confidence] + + +def _render_bounded_replacement( + text: str, + detections: tuple[EntityDetection, ...], + template: str, +) -> str: + projected_size = 0 + cursor = 0 + for detection in detections: + projected_size += len(text[cursor : detection.start].encode("utf-8")) + projected_size += _rendered_template_size(template, detection.entity) + if projected_size > MAX_BODY_BYTES: + raise EngineLimitExceededError("regex replacement exceeds the size limit") + cursor = detection.end + projected_size += len(text[cursor:].encode("utf-8")) + if projected_size > MAX_BODY_BYTES: + raise EngineLimitExceededError("regex replacement exceeds the size limit") + + parts: list[str] = [] + cursor = 0 + for detection in detections: + parts.append(text[cursor : detection.start]) + parts.append(template.format(entity=detection.entity)) + cursor = detection.end + parts.append(text[cursor:]) + return "".join(parts) + + +def _rendered_template_size(template: str, entity: str) -> int: + size = 0 + entity_size = len(entity.encode("utf-8")) + for literal, field_name, _, _ in Formatter().parse(template): + size += len(literal.encode("utf-8")) + if field_name is not None: + size += entity_size + return size + + +_NAME_PATTERN = regex.compile(r"[A-Za-z_][A-Za-z0-9_-]*\Z") +_INLINE_FLAG_PATTERN = regex.compile(r"[A-Za-z0-9-]+(?=[:)])") +_CONFIDENCE_RANK = { + ConfidenceLevel.LOW: 0, + ConfidenceLevel.MEDIUM: 1, + ConfidenceLevel.HIGH: 2, +} +_RULE_METADATA_KEY = "rule" +_MAX_CACHED_COMPILED_CATALOGS = 128 +# Python dict preserves insertion order, but this LRU must move cache hits to the +# newest position and efficiently evict the oldest entry. +_COMPILED_PATTERN_CACHE: OrderedDict[ + RegexPatternCatalog, + tuple[tuple[_CompiledRule, ...], int], +] = OrderedDict() +_COMPILED_PATTERN_CACHE_WEIGHT_BYTES = 0 +_COMPILED_PATTERN_CACHE_LOCK = RLock() +_LOGGER = get_logger(__name__) + + +__all__ = [ + "RegexEngine", + "RegexEngineConfig", + "RegexEntity", + "RegexPatternCatalog", + "RegexReplacement", + "RegexRule", +] diff --git a/projects/privacy-guard/src/privacy_guard/engines/registry.py b/projects/privacy-guard/src/privacy_guard/engines/registry.py new file mode 100644 index 0000000..bee9deb --- /dev/null +++ b/projects/privacy-guard/src/privacy_guard/engines/registry.py @@ -0,0 +1,318 @@ +"""Engine registration and finalized policy-schema construction.""" + +from __future__ import annotations + +import inspect +import re +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from functools import reduce +from operator import or_ +from types import NoneType +from typing import Annotated, Literal, Self, get_args, get_origin + +from pydantic import Field, TypeAdapter, ValidationError +from pydantic_core import PydanticUndefined + +from privacy_guard.config import ( + PolicyAction, + PrivacyGuardConfig, +) +from privacy_guard.engines.base import ( + EngineConfig, + EngineResources, + EntityProcessingEngine, + EntityProcessingStrategy, +) +from privacy_guard.engines.regex import ( + RegexEngine, +) +from privacy_guard.errors import ( + EngineConfigurationError, + EngineRegistryError, + ErrorCode, + PrivacyGuardError, +) + + +@dataclass(frozen=True) +class EngineDescription: + """Safe discovery metadata for one registered engine.""" + + engine_name: str + description: str + supported_strategies: frozenset[EntityProcessingStrategy] + + +class EngineRegistry: + """Register engine implementations and finalize their exact policy union.""" + + def __init__(self, *, include_builtin_engines: bool = False) -> None: + self._registrations: dict[str, _Registration] = {} + self._config_adapter: TypeAdapter[PrivacyGuardConfig[EngineConfig]] | None = ( + None + ) + if include_builtin_engines: + self.register(RegexEngine) + + @property + def is_finalized(self) -> bool: + return self._config_adapter is not None + + def register( + self, + engine_type: type[object], + *, + resources: object = None, + ) -> None: + """Register one engine implementation and its operator-owned resources.""" + if self.is_finalized: + raise EngineRegistryError("cannot register after finalization") + if not isinstance(engine_type, type) or not issubclass( + engine_type, EntityProcessingEngine + ): + raise EngineRegistryError("registered engine type is invalid") + if engine_type.__init__ is not EntityProcessingEngine.__init__: + raise EngineRegistryError( + "engine lifecycle contract requires EntityProcessingEngine.__init__; " + "use _initialize() instead" + ) + if engine_type.run is not EntityProcessingEngine.run: + raise EngineRegistryError( + "engine lifecycle contract requires EntityProcessingEngine.run; " + "implement _run() instead" + ) + + try: + config_type = engine_type.get_config_type() + resources_type = engine_type.get_resources_type() + except (AttributeError, TypeError): + raise EngineRegistryError("engine generic declaration is invalid") from None + if not isinstance(config_type, type) or not issubclass( + config_type, EngineConfig + ): + raise EngineRegistryError("engine config type is invalid") + resources_runtime_type = ( + NoneType + if resources_type is None + else get_origin(resources_type) or resources_type + ) + if not isinstance(resources_runtime_type, type): + raise EngineRegistryError("engine resources type is invalid") + if resources_runtime_type is not NoneType and not issubclass( + resources_runtime_type, + EngineResources, + ): + raise EngineRegistryError( + "engine resources type must extend EngineResources" + ) + + engine_name = _engine_discriminator(config_type) + if engine_name in self._registrations: + raise EngineRegistryError("engine discriminator is already registered") + if any( + registration.config_type is config_type + for registration in self._registrations.values() + ): + raise EngineRegistryError("engine config type is already registered") + + _supported_strategies(engine_type) + if resources_runtime_type is NoneType: + if resources is not None: + raise EngineRegistryError("resource-free engine received resources") + else: + if resources is not None and not isinstance(resources, EngineResources): + raise EngineRegistryError( + "engine resources must extend EngineResources" + ) + if resources is None or not isinstance(resources, resources_runtime_type): + raise EngineRegistryError( + "engine resources do not match their declared type" + ) + + self._registrations[engine_name] = _Registration( + engine_type=engine_type, + config_type=config_type, + resources=resources, + ) + + def finalize(self) -> Self: + """Freeze registrations, build the policy union, and return this registry.""" + if self.is_finalized: + return self + try: + config_type = _build_privacy_guard_config_type( + tuple( + registration.config_type + for registration in self._registrations.values() + ) + ) + except ValueError: + raise EngineRegistryError( + "cannot finalize an empty engine registry" + ) from None + self._config_adapter = TypeAdapter(config_type) + return self + + def validate_config(self, values: object) -> PrivacyGuardConfig[EngineConfig]: + """Purely parse and validate an expanded Privacy Guard configuration.""" + if not isinstance(values, Mapping): + raise PrivacyGuardError(ErrorCode.CONFIG_INVALID) + try: + config = self._require_config_adapter().validate_python(dict(values)) + except (TypeError, ValueError, ValidationError): + raise PrivacyGuardError(ErrorCode.CONFIG_INVALID) from None + required_strategy = ( + EntityProcessingStrategy.REPLACE + if config.on_detection.action is PolicyAction.REPLACE + else EntityProcessingStrategy.DETECT + ) + for stage in config.entity_processing.stages: + registration = self._resolve_registration(stage.config) + engine_type = registration.engine_type + if not issubclass(engine_type, EntityProcessingEngine): + raise EngineRegistryError("registered engine type is invalid") + try: + validate_run_config = getattr(engine_type, "validate_run_config") + validate_run_config( + stage.config, + registration.resources, + strategy=required_strategy, + ) + except EngineConfigurationError: + raise PrivacyGuardError(ErrorCode.CONFIG_INVALID) from None + return config + + def create_engine( + self, + config: EngineConfig, + ) -> EntityProcessingEngine[EngineConfig, EngineResources | None]: + """Construct an initialized engine from its exact validated config.""" + registration = self._resolve_registration(config) + if type(config) is not registration.config_type: + raise EngineRegistryError("engine config concrete type is invalid") + return registration.engine_type(config, registration.resources) + + def configuration_json_schema(self) -> dict[str, object]: + """Return the finalized complete policy JSON Schema.""" + return self._require_config_adapter().json_schema() + + def describe_engines(self) -> tuple[EngineDescription, ...]: + """Return safe engine metadata without constructing runtime engines.""" + return tuple( + EngineDescription( + engine_name=engine, + description=_engine_description(registration.engine_type), + supported_strategies=_supported_strategies(registration.engine_type), + ) + for engine, registration in self._registrations.items() + ) + + def _resolve_registration( + self, + config: EngineConfig, + ) -> _Registration: + if not self.is_finalized: + raise EngineRegistryError("engine registry is not finalized") + try: + engine_name = getattr(config, "engine") + if not isinstance(engine_name, str): + raise AttributeError + registration = self._registrations[engine_name] + except (AttributeError, KeyError): + raise EngineRegistryError("engine config is not registered") from None + return registration + + def _require_config_adapter( + self, + ) -> TypeAdapter[PrivacyGuardConfig[EngineConfig]]: + if self._config_adapter is None: + raise EngineRegistryError("engine registry is not finalized") + return self._config_adapter + + +def create_builtin_registry() -> EngineRegistry: + """Build the finalized registry shipped by the base package.""" + return EngineRegistry(include_builtin_engines=True).finalize() + + +@dataclass(frozen=True) +class _Registration: + engine_type: type[object] + config_type: type[EngineConfig] + resources: EngineResources | None + + +def _build_privacy_guard_config_type( + config_types: Sequence[type[EngineConfig]], +) -> type[PrivacyGuardConfig[EngineConfig]]: + if not config_types: + raise ValueError("at least one engine config type must be registered") + registered_union = reduce(or_, config_types) + registered_config = Annotated[ + registered_union, # ty: ignore[invalid-type-form] + Field(discriminator="engine"), + ] + config_type = PrivacyGuardConfig.__class_getitem__( + registered_config # ty: ignore[invalid-argument-type] + ) + if not isinstance(config_type, type) or not issubclass( + config_type, PrivacyGuardConfig + ): + raise TypeError("Pydantic did not construct a policy config type") + return config_type # ty: ignore[invalid-return-type] + + +def _supported_strategies( + engine_type: type[object], +) -> frozenset[EntityProcessingStrategy]: + supported_strategies = getattr(engine_type, "supported_strategies", None) + if ( + not isinstance(supported_strategies, frozenset) + or not supported_strategies + or any( + not isinstance(strategy, EntityProcessingStrategy) + for strategy in supported_strategies + ) + ): + raise EngineRegistryError("engine supported strategies are invalid") + return supported_strategies + + +def _engine_discriminator( + config_type: type[EngineConfig], +) -> str: + field = config_type.model_fields.get("engine") + if field is None: + raise EngineRegistryError("engine config lacks an engine discriminator") + if get_origin(field.annotation) is not Literal: + raise EngineRegistryError("engine discriminator must be one string Literal") + values = get_args(field.annotation) + if len(values) != 1 or not isinstance(values[0], str): + raise EngineRegistryError("engine discriminator must be one string Literal") + engine = values[0] + if _ENGINE_NAME.fullmatch(engine) is None or len(engine.encode("ascii")) > 128: + raise EngineRegistryError("engine discriminator is invalid") + if field.default is not PydanticUndefined and field.default != engine: + raise EngineRegistryError("engine discriminator default is inconsistent") + return engine + + +def _engine_description( + engine_type: type[object], +) -> str: + description = inspect.getdoc(engine_type) or "" + first_line = description.splitlines()[0] if description else "" + if len(first_line.encode("utf-8")) > 1024: + return "" + return first_line + + +_ENGINE_NAME = re.compile(r"[a-z][a-z0-9-]{0,127}\Z") + + +__all__ = [ + "EngineDescription", + "EngineRegistry", + "create_builtin_registry", +] diff --git a/projects/privacy-guard/src/privacy_guard/errors.py b/projects/privacy-guard/src/privacy_guard/errors.py new file mode 100644 index 0000000..84e58be --- /dev/null +++ b/projects/privacy-guard/src/privacy_guard/errors.py @@ -0,0 +1,194 @@ +"""Content-safe failures shared across Privacy Guard trust boundaries.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + +from privacy_guard.constants import MAX_PROTO_CONFIG_BYTES, MAX_TIMEOUT_SECONDS + + +class ErrorKind(StrEnum): + """Whether a failure is attributable to input or middleware internals.""" + + INVALID_INPUT = "invalid_input" + INTERNAL = "internal" + + +class ErrorComponent(StrEnum): + """The Privacy Guard component responsible for a failure.""" + + CONFIG = "config" + ENGINE = "engine" + PROCESSOR = "processor" + SERVICE = "service" + SERVER = "server" + + +class ErrorCode(StrEnum): + """Stable identifiers for cataloged production failures.""" + + CONFIG_INVALID = "config_invalid" + REQUEST_PHASE_INVALID = "request_phase_invalid" + REQUEST_ENVELOPE_INVALID = "request_envelope_invalid" + REQUEST_BODY_TOO_LARGE = "request_body_too_large" + BODY_ENCODING_INVALID = "body_encoding_invalid" + ENGINE_OUTPUT_INVALID = "engine_output_invalid" + ENGINE_EXECUTION_FAILED = "engine_execution_failed" + SERVER_BIND_FAILED = "server_bind_failed" + UNEXPECTED_SERVICE_FAILURE = "unexpected_service_failure" + + +@dataclass(frozen=True) +class _ErrorSpec: + """Immutable, developer-authored classification and remediation text.""" + + kind: ErrorKind + component: ErrorComponent + operation: str + summary: str + hint: str + + +class PrivacyGuardError(Exception): + """A catalog-only failure whose public representation is content-safe.""" + + def __init__(self, code: ErrorCode) -> None: + self.code = code + self._spec = _ERROR_SPECS[code] + super().__init__(str(self)) + + @property + def kind(self) -> ErrorKind: + return self._spec.kind + + @property + def component(self) -> ErrorComponent: + return self._spec.component + + @property + def operation(self) -> str: + return self._spec.operation + + @property + def summary(self) -> str: + return self._spec.summary + + @property + def hint(self) -> str: + return self._spec.hint + + def __str__(self) -> str: + return ( + f"[{self.code.value}] {self.component.value}.{self.operation}: " + f"{self.summary} Hint: {self.hint}" + ) + + +class EntityProcessingError(Exception): + """Base for content-safe entity-processing failures.""" + + +class EngineConfigurationError(EntityProcessingError): + """An engine class or configured instance is invalid.""" + + +class EngineContractError(EntityProcessingError): + """An engine invocation or returned result violated the public contract.""" + + +class EngineExecutionError(EntityProcessingError): + """An engine's configured runtime failed to complete one text input.""" + + +class EngineLimitExceededError(EntityProcessingError): + """An engine exceeded a bounded configuration or output limit.""" + + +class TimeoutExpiredError(EntityProcessingError): + """The shared entity-processing timeout expired.""" + + def __init__(self) -> None: + super().__init__( + "Privacy Guard processing timed out. Reduce the request size or simplify " + "the configured stages and rules, or increase the processing timeout " + f"to at most {MAX_TIMEOUT_SECONDS:g} seconds, then retry." + ) + + +class EngineRegistryError(Exception): + """A content-safe engine registration or registry lifecycle failure.""" + + +_ERROR_SPECS: dict[ErrorCode, _ErrorSpec] = { + ErrorCode.CONFIG_INVALID: _ErrorSpec( + ErrorKind.INVALID_INPUT, + ErrorComponent.CONFIG, + "parse", + "Policy configuration is invalid.", + "Keep the encoded configuration at or below " + f"{MAX_PROTO_CONFIG_BYTES // 1024} KiB, compare it with " + "`privacy-guard configuration-schema`, then check the stages, engine " + "settings, pattern catalogs, replacements, and action.", + ), + ErrorCode.REQUEST_PHASE_INVALID: _ErrorSpec( + ErrorKind.INVALID_INPUT, + ErrorComponent.SERVICE, + "validate_phase", + "Request evaluation phase is invalid.", + "Use the advertised pre-credentials phase.", + ), + ErrorCode.REQUEST_ENVELOPE_INVALID: _ErrorSpec( + ErrorKind.INVALID_INPUT, + ErrorComponent.SERVICE, + "validate_envelope", + "Request transport metadata exceeds an advertised limit.", + "Reduce the request context, target, or headers to the OpenShell " + "middleware contract limits.", + ), + ErrorCode.REQUEST_BODY_TOO_LARGE: _ErrorSpec( + ErrorKind.INVALID_INPUT, + ErrorComponent.SERVICE, + "validate_body_size", + "Request body exceeds the advertised size limit.", + "Reduce the request body to the maximum size in the middleware manifest.", + ), + ErrorCode.BODY_ENCODING_INVALID: _ErrorSpec( + ErrorKind.INVALID_INPUT, + ErrorComponent.SERVICE, + "decode_text", + "Request body encoding is invalid.", + "Supply a valid UTF-8 request body.", + ), + ErrorCode.ENGINE_OUTPUT_INVALID: _ErrorSpec( + ErrorKind.INTERNAL, + ErrorComponent.PROCESSOR, + "validate_engine", + "An entity-processing engine returned an invalid result.", + "Custom engine developers should check the run contract, result model, " + "spans, processing strategy, and output limits.", + ), + ErrorCode.ENGINE_EXECUTION_FAILED: _ErrorSpec( + ErrorKind.INTERNAL, + ErrorComponent.ENGINE, + "run", + "An entity-processing engine failed.", + "Check the request ID and error code in service logs, then run the " + "configured engine's focused configuration and single-text tests.", + ), + ErrorCode.SERVER_BIND_FAILED: _ErrorSpec( + ErrorKind.INTERNAL, + ErrorComponent.SERVER, + "start", + "Server could not start on its listen address.", + "Choose an available listen address and port, then retry.", + ), + ErrorCode.UNEXPECTED_SERVICE_FAILURE: _ErrorSpec( + ErrorKind.INTERNAL, + ErrorComponent.SERVICE, + "evaluate_http_request", + "The middleware encountered an unexpected failure.", + "Retry once; if it recurs, report the request ID and error code from the " + "service log.", + ), +} diff --git a/projects/privacy-guard/src/privacy_guard/gateway_config.py b/projects/privacy-guard/src/privacy_guard/gateway_config.py new file mode 100644 index 0000000..8ad54d6 --- /dev/null +++ b/projects/privacy-guard/src/privacy_guard/gateway_config.py @@ -0,0 +1,294 @@ +"""OpenShell gateway configuration updates for the command-line application.""" + +from __future__ import annotations + +import os +import re +import stat +import tempfile +import tomllib +from enum import Enum +from pathlib import Path + + +class GatewayConfigUpdate(Enum): + """Result of writing one Privacy Guard middleware registration.""" + + CREATED = "created" + ADDED = "added" + UPDATED = "updated" + UNCHANGED = "unchanged" + + +class GatewayConfigError(ValueError): + """A safe, actionable gateway configuration update error.""" + + +# Mirrors OpenShell's stable-identifier byte limit for external middleware +# registrations. +MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES = 128 + + +def default_gateway_config_path() -> Path: + """Resolve OpenShell's config override or standard per-user gateway path.""" + # Mirrors the OpenShell gateway's `--config` environment override and XDG + # fallback. Package-specific deployments can still pass --config explicitly. + configured_path = os.environ.get("OPENSHELL_GATEWAY_CONFIG") + if configured_path: + return Path(configured_path) + config_home = os.environ.get("XDG_CONFIG_HOME") + if config_home: + return Path(config_home) / "openshell" / "gateway.toml" + return Path.home() / ".config" / "openshell" / "gateway.toml" + + +def update_gateway_config( + path: Path, + *, + middleware_name: str, + host_ip: str, + port: int, +) -> GatewayConfigUpdate: + """Add or update one named Privacy Guard middleware registration.""" + validate_middleware_name(middleware_name) + endpoint = f"http://{host_ip}:{port}" + try: + original = path.read_text(encoding="utf-8") + except FileNotFoundError: + updated = _new_gateway_config( + middleware_name=middleware_name, + endpoint=endpoint, + ) + _write_atomically(path, updated) + return GatewayConfigUpdate.CREATED + except (OSError, UnicodeError) as error: + raise GatewayConfigError( + f"Could not read {path}. Check that the file is readable UTF-8 TOML." + ) from error + + if not original.strip(): + updated = _new_gateway_config( + middleware_name=middleware_name, + endpoint=endpoint, + ) + _write_atomically(path, updated) + return GatewayConfigUpdate.CREATED + + values = _load_gateway_config(original, path) + middleware = _middleware_entries(values, path) + matching_indexes = [ + index + for index, entry in enumerate(middleware) + if entry.get("name") == middleware_name + ] + if len(matching_indexes) > 1: + raise GatewayConfigError( + f"{path} contains multiple middleware registrations named " + f"{middleware_name!r}. Remove the duplicate entries, then retry." + ) + + blocks = list(_MIDDLEWARE_BLOCK_PATTERN.finditer(original)) + if len(blocks) != len(middleware): + raise GatewayConfigError( + f"Could not safely locate every middleware registration in {path}. " + "Format the file as standard TOML tables, then retry." + ) + + if matching_indexes: + block = blocks[matching_indexes[0]] + replacement = _update_middleware_block( + block.group(0), + endpoint=endpoint, + ) + updated = original[: block.start()] + replacement + original[block.end() :] + result = GatewayConfigUpdate.UPDATED + else: + updated = _append_middleware_block( + original, + middleware_name=middleware_name, + endpoint=endpoint, + ) + result = GatewayConfigUpdate.ADDED + + _load_gateway_config(updated, path) + if updated == original: + return GatewayConfigUpdate.UNCHANGED + _write_atomically(path, updated) + return result + + +def validate_middleware_name(name: str) -> str: + """Validate OpenShell's external middleware registration-name contract.""" + if ( + not name + or len(name.encode("utf-8")) > MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES + or any(character not in _MIDDLEWARE_NAME_CHARACTERS for character in name) + ): + raise GatewayConfigError( + "Middleware names must use " + f"1-{MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES} ASCII bytes containing " + "only letters, digits, '.', '_', '-', or '/'." + ) + if name.startswith("openshell/"): + raise GatewayConfigError( + "Operator-owned middleware names cannot start with 'openshell/'." + ) + return name + + +def _new_gateway_config(*, middleware_name: str, endpoint: str) -> str: + return "[openshell]\nversion = 1\n\n" + _middleware_block( + middleware_name=middleware_name, + endpoint=endpoint, + ) + + +def _load_gateway_config(contents: str, path: Path) -> dict[str, object]: + try: + values = tomllib.loads(contents) + except tomllib.TOMLDecodeError as error: + raise GatewayConfigError( + f"{path} is not valid TOML. Fix the reported TOML syntax, then retry." + ) from error + openshell = values.get("openshell") + if not isinstance(openshell, dict) or openshell.get("version") != 1: + raise GatewayConfigError( + f"{path} must contain [openshell] with version = 1. " + "Fix the gateway config version, then retry." + ) + return values + + +def _middleware_entries( + values: dict[str, object], + path: Path, +) -> list[dict[str, object]]: + openshell = values["openshell"] + if not isinstance(openshell, dict): + raise AssertionError("validated OpenShell table is unavailable") + supervisor = openshell.get("supervisor") + if supervisor is None: + return [] + if not isinstance(supervisor, dict): + raise GatewayConfigError( + f"{path} has an invalid [openshell.supervisor] value. " + "Replace it with a TOML table, then retry." + ) + middleware = supervisor.get("middleware") + if middleware is None: + return [] + if not isinstance(middleware, list): + raise GatewayConfigError( + f"{path} has an invalid openshell.supervisor.middleware value. " + "Use [[openshell.supervisor.middleware]] tables, then retry." + ) + entries: list[dict[str, object]] = [] + for entry in middleware: + if not isinstance(entry, dict) or not all( + isinstance(key, str) for key in entry + ): + raise GatewayConfigError( + f"{path} has an invalid openshell.supervisor.middleware value. " + "Use [[openshell.supervisor.middleware]] tables, then retry." + ) + entries.append({str(key): value for key, value in entry.items()}) + return entries + + +def _append_middleware_block( + contents: str, + *, + middleware_name: str, + endpoint: str, +) -> str: + return ( + contents.rstrip() + + "\n\n" + + _middleware_block( + middleware_name=middleware_name, + endpoint=endpoint, + ) + ) + + +def _middleware_block(*, middleware_name: str, endpoint: str) -> str: + return ( + "[[openshell.supervisor.middleware]]\n" + f'name = "{middleware_name}"\n' + f'grpc_endpoint = "{endpoint}"\n' + "max_body_bytes = 4194304\n" + 'timeout = "5s"\n' + ) + + +def _update_middleware_block(block: str, *, endpoint: str) -> str: + updated = _replace_or_append_assignment( + block, + key="grpc_endpoint", + value=f'"{endpoint}"', + ) + updated = _replace_or_append_assignment( + updated, + key="max_body_bytes", + value="4194304", + ) + return _replace_or_append_assignment( + updated, + key="timeout", + value='"5s"', + ) + + +def _replace_or_append_assignment(block: str, *, key: str, value: str) -> str: + pattern = re.compile( + rf"^(?P[ \t]*){re.escape(key)}[ \t]*=.*?$", + flags=re.MULTILINE, + ) + if pattern.search(block): + return pattern.sub( + lambda match: f"{match.group('indent')}{key} = {value}", + block, + count=1, + ) + return block.rstrip() + f"\n{key} = {value}\n" + + +def _write_atomically(path: Path, contents: str) -> None: + try: + path.parent.mkdir(parents=True, exist_ok=True) + mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else 0o600 + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=path.parent, + prefix=f".{path.name}.", + delete=False, + ) as temporary: + temporary.write(contents) + temporary_path = Path(temporary.name) + temporary_path.chmod(mode) + temporary_path.replace(path) + except OSError as error: + raise GatewayConfigError( + f"Could not write {path}. Check that its directory is writable, then retry." + ) from error + + +_MIDDLEWARE_BLOCK_PATTERN = re.compile( + r"(?ms)^[ \t]*\[\[openshell\.supervisor\.middleware\]\][^\n]*\n" + r".*?(?=^[ \t]*\[\[?[A-Za-z0-9_-]|\Z)" +) + +_MIDDLEWARE_NAME_CHARACTERS = frozenset( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-/" +) + + +__all__ = [ + "GatewayConfigError", + "GatewayConfigUpdate", + "MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES", + "default_gateway_config_path", + "update_gateway_config", + "validate_middleware_name", +] diff --git a/projects/privacy-guard/src/privacy_guard/logging.py b/projects/privacy-guard/src/privacy_guard/logging.py new file mode 100644 index 0000000..3489beb --- /dev/null +++ b/projects/privacy-guard/src/privacy_guard/logging.py @@ -0,0 +1,131 @@ +"""Native Python logging configuration for Privacy Guard.""" + +from __future__ import annotations + +import copy +import logging +from dataclasses import dataclass +from enum import StrEnum +from typing import TextIO + + +class ColorMode(StrEnum): + """When Privacy Guard should add ANSI colors to console logs.""" + + AUTO = "auto" + ALWAYS = "always" + NEVER = "never" + + +@dataclass(frozen=True) +class LoggingConfig: + """Privacy Guard console logging settings.""" + + level: int | str = logging.INFO + stream: TextIO | None = None + color_mode: ColorMode = ColorMode.AUTO + + +DEFAULT_LOGGING_CONFIG = LoggingConfig() + + +def get_logger(name: str) -> logging.Logger: + """Return a logger governed by Privacy Guard's shared configuration.""" + return logging.getLogger(name) + + +def configure_logging( + config: LoggingConfig = DEFAULT_LOGGING_CONFIG, +) -> None: + """Configure consistent console logging for the Privacy Guard package. + + Repeated calls replace the handler installed by this function. Handlers + installed by the containing application are left unchanged. + """ + package_logger = get_logger("privacy_guard") + package_logger.setLevel(config.level) + + for handler in package_logger.handlers[:]: + if isinstance(handler, _PrivacyGuardStreamHandler): + package_logger.removeHandler(handler) + handler.close() + + handler = _PrivacyGuardStreamHandler(config.stream) + use_colors = ( + handler.stream.isatty() + if config.color_mode is ColorMode.AUTO + else config.color_mode is ColorMode.ALWAYS + ) + handler.setFormatter(_PrivacyGuardFormatter(use_colors=use_colors)) + package_logger.addHandler(handler) + package_logger.propagate = False + + +def reset_logging() -> None: + """Remove logging configuration installed by :func:`configure_logging`.""" + package_logger = get_logger("privacy_guard") + managed_handlers = [ + handler + for handler in package_logger.handlers + if isinstance(handler, _PrivacyGuardStreamHandler) + ] + if not managed_handlers: + return + + for handler in managed_handlers: + package_logger.removeHandler(handler) + handler.close() + package_logger.setLevel(logging.NOTSET) + package_logger.propagate = True + + +class _PrivacyGuardStreamHandler(logging.StreamHandler[TextIO]): + """Stream handler owned by Privacy Guard's logging configuration.""" + + +class _PrivacyGuardFormatter(logging.Formatter): + """Readable console formatter with optional level-aware color.""" + + def __init__(self, *, use_colors: bool) -> None: + log_format = _PLAIN_LOG_FORMAT + if use_colors: + log_format = _COLOR_LOG_FORMAT + super().__init__(log_format, datefmt="%Y-%m-%d %H:%M:%S") + self._use_colors = use_colors + + def format(self, record: logging.LogRecord) -> str: + formatted_record = copy.copy(record) + level_name = f"{record.levelname:<8}" + if self._use_colors: + color = _LEVEL_COLORS.get(record.levelno, _ANSI_BRIGHT_BLACK) + level_name = f"{color}{level_name}{_ANSI_RESET}" + formatted_record.levelname = level_name + return super().format(formatted_record) + + +_PLAIN_LOG_FORMAT = "%(asctime)s | %(levelname)s | %(name)s | %(message)s" +_ANSI_RESET = "\033[0m" +_ANSI_DIM = "\033[2m" +_ANSI_BRIGHT_BLACK = "\033[90m" +_ANSI_CYAN = "\033[36m" +_COLOR_LOG_FORMAT = ( + f"{_ANSI_DIM}%(asctime)s{_ANSI_RESET} | " + f"%(levelname)s | {_ANSI_CYAN}%(name)s{_ANSI_RESET} | %(message)s" +) +_LEVEL_COLORS = { + logging.DEBUG: _ANSI_BRIGHT_BLACK, + logging.INFO: "\033[32m", + logging.WARNING: "\033[33m", + logging.ERROR: "\033[31m", + logging.CRITICAL: "\033[1;31m", +} + + +__all__ = [ + "ColorMode", + "DEFAULT_LOGGING_CONFIG", + "LoggingConfig", + "configure_logging", + "get_logger", + "reset_logging", +] diff --git a/projects/privacy-guard/src/privacy_guard/request_processor.py b/projects/privacy-guard/src/privacy_guard/request_processor.py new file mode 100644 index 0000000..459f6fb --- /dev/null +++ b/projects/privacy-guard/src/privacy_guard/request_processor.py @@ -0,0 +1,212 @@ +"""Sequential entity-processing orchestration for one text input.""" + +from __future__ import annotations + +from collections.abc import Sequence +from enum import StrEnum + +from pydantic import Field + +from privacy_guard.base import StrictDomainModel +from privacy_guard.config import PolicyAction, PrivacyGuardConfig +from privacy_guard.constants import ( + BLOCK_REASON_CODE, + DEFAULT_TIMEOUT_SECONDS, + LIMIT_REASON_CODE, + MAX_BODY_BYTES, + MAX_DETECTIONS_PER_REQUEST, +) +from privacy_guard.engines import ( + ConfidenceLevel, + EngineConfig, + EngineResources, + EntityProcessingEngine, + EntityProcessingStrategy, + TextProcessingResult, +) +from privacy_guard.errors import ( + EngineConfigurationError, + EngineContractError, + EngineLimitExceededError, + EntityProcessingError, + ErrorCode, + PrivacyGuardError, + TimeoutExpiredError, +) +from privacy_guard.logging import get_logger +from privacy_guard.string_validators import validate_scalar_string +from privacy_guard.timeout import Timeout, validate_timeout_seconds + + +class RequestDecision(StrEnum): + """Whether OpenShell should continue or stop the request.""" + + ALLOW = "allow" + DENY = "deny" + + +class EntityDetectionSummary(StrictDomainModel): + """One bounded aggregate suitable for user-facing audit output.""" + + entity: str + source_stage: str + confidence: ConfidenceLevel | None = None + count: int = Field(ge=1) + + +class RequestProcessingResult(StrictDomainModel): + """The processor's decision, summaries, and optional replacement text.""" + + decision: RequestDecision + replacement_text: str | None = Field(default=None, repr=False) + detection_summaries: tuple[EntityDetectionSummary, ...] = () + reason_code: str | None = None + + +class RequestProcessor: + """Run configured entity-processing stages once, in policy order.""" + + def __init__( + self, + config: PrivacyGuardConfig[EngineConfig], + configured_engines: Sequence[ + tuple[ + str, + EntityProcessingEngine[EngineConfig, EngineResources | None], + ] + ], + *, + timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, + log_request_content: bool = False, + ) -> None: + engines = tuple(configured_engines) + if len(engines) != len(config.entity_processing.stages): + raise ValueError("configured engines do not match the policy") + if not engines: + raise ValueError("at least one configured engine is required") + sources = tuple(source for source, _ in engines) + if any(not source for source in sources) or len(sources) != len(set(sources)): + raise ValueError("engine sources must be non-empty and unique") + self._config = config + self._engines = engines + self._timeout_seconds = validate_timeout_seconds(timeout_seconds) + self._log_request_content = log_request_content + + def process(self, text: str) -> RequestProcessingResult: + """Process one complete request text and apply the user-facing action.""" + try: + input_text = validate_scalar_string(text) + except ValueError: + raise PrivacyGuardError(ErrorCode.BODY_ENCODING_INVALID) from None + if len(input_text.encode("utf-8")) > MAX_BODY_BYTES: + raise PrivacyGuardError(ErrorCode.REQUEST_BODY_TOO_LARGE) + if self._log_request_content: + _LOGGER.debug("privacy_guard_text_input text=%r", input_text) + + action = self._config.on_detection.action + strategy = ( + EntityProcessingStrategy.REPLACE + if action is PolicyAction.REPLACE + else EntityProcessingStrategy.DETECT + ) + timeout = Timeout.from_seconds(self._timeout_seconds) + current_text = input_text + stage_results: list[tuple[str, TextProcessingResult]] = [] + try: + for source, engine in self._engines: + _LOGGER.debug( + "privacy_guard_stage_run source=%s strategy=%s", + source, + strategy.value, + ) + result = engine.run( + current_text, + strategy=strategy, + timeout=timeout, + ) + if len(result.text.encode("utf-8")) > MAX_BODY_BYTES: + raise EngineLimitExceededError( + "intermediate text exceeds the limit" + ) + if ( + sum(len(item.detections) for _, item in stage_results) + + len(result.detections) + > MAX_DETECTIONS_PER_REQUEST + ): + raise EngineLimitExceededError( + "request detections exceed the limit" + ) + stage_results.append((source, result)) + current_text = result.text + timeout.raise_if_expired() + except TimeoutExpiredError: + _LOGGER.info("privacy_guard_processing_limit kind=timeout") + return RequestProcessingResult( + decision=RequestDecision.DENY, + reason_code=LIMIT_REASON_CODE, + ) + except EngineLimitExceededError: + _LOGGER.info("privacy_guard_processing_limit kind=resource") + return RequestProcessingResult( + decision=RequestDecision.DENY, + reason_code=LIMIT_REASON_CODE, + ) + except EngineConfigurationError: + raise PrivacyGuardError(ErrorCode.CONFIG_INVALID) from None + except EngineContractError: + raise PrivacyGuardError(ErrorCode.ENGINE_OUTPUT_INVALID) from None + except EntityProcessingError: + raise PrivacyGuardError(ErrorCode.ENGINE_EXECUTION_FAILED) from None + except PrivacyGuardError: + raise + except Exception: + raise PrivacyGuardError(ErrorCode.ENGINE_EXECUTION_FAILED) from None + + detections = _aggregate_detections(stage_results) + if action is PolicyAction.BLOCK and detections: + return RequestProcessingResult( + decision=RequestDecision.DENY, + detection_summaries=detections, + reason_code=BLOCK_REASON_CODE, + ) + replacement_text = current_text if action is PolicyAction.REPLACE else None + if self._log_request_content: + _LOGGER.debug("privacy_guard_text_output text=%r", current_text) + return RequestProcessingResult( + decision=RequestDecision.ALLOW, + replacement_text=replacement_text, + detection_summaries=detections, + ) + + +def _aggregate_detections( + stage_results: Sequence[tuple[str, TextProcessingResult]], +) -> tuple[EntityDetectionSummary, ...]: + groups: dict[ + tuple[str, str, ConfidenceLevel | None], + int, + ] = {} + for source, result in stage_results: + for detection in result.detections: + key = (source, detection.entity, detection.confidence) + groups[key] = groups.get(key, 0) + 1 + return tuple( + EntityDetectionSummary( + source_stage=source, + entity=entity, + confidence=confidence, + count=count, + ) + for (source, entity, confidence), count in groups.items() + ) + + +_LOGGER = get_logger(__name__) + + +__all__ = [ + "EntityDetectionSummary", + "RequestDecision", + "RequestProcessingResult", + "RequestProcessor", +] diff --git a/projects/privacy-guard/src/privacy_guard/service/__init__.py b/projects/privacy-guard/src/privacy_guard/service/__init__.py new file mode 100644 index 0000000..d492388 --- /dev/null +++ b/projects/privacy-guard/src/privacy_guard/service/__init__.py @@ -0,0 +1,6 @@ +"""gRPC transport and servicer for the Privacy Guard middleware.""" + +from privacy_guard.service.server import PrivacyGuardServer +from privacy_guard.service.servicer import PrivacyGuardMiddleware + +__all__ = ["PrivacyGuardMiddleware", "PrivacyGuardServer"] diff --git a/projects/privacy-guard/src/privacy_guard/service/server.py b/projects/privacy-guard/src/privacy_guard/service/server.py new file mode 100644 index 0000000..4465c04 --- /dev/null +++ b/projects/privacy-guard/src/privacy_guard/service/server.py @@ -0,0 +1,124 @@ +"""Programmatic Privacy Guard gRPC server lifecycle.""" + +from __future__ import annotations + +import asyncio + +import grpc + +from privacy_guard.bindings import supervisor_middleware_pb2_grpc as pb2_grpc +from privacy_guard.constants import ( + DEFAULT_TIMEOUT_SECONDS, + MAX_CONCURRENT_RPCS, + MAX_RECEIVE_MESSAGE_BYTES, +) +from privacy_guard.engines.registry import EngineRegistry +from privacy_guard.errors import ErrorCode, PrivacyGuardError +from privacy_guard.logging import get_logger +from privacy_guard.service.servicer import PrivacyGuardMiddleware + +DEFAULT_LISTEN_ADDRESS = "127.0.0.1:50051" + + +class PrivacyGuardServer: + """One-shot programmatic server for a finalized engine registry.""" + + def __init__( + self, + registry: EngineRegistry, + *, + timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, + log_request_content: bool = False, + ) -> None: + self._middleware = PrivacyGuardMiddleware( + registry, + timeout_seconds=timeout_seconds, + log_request_content=log_request_content, + ) + + def serve_sync(self, listen: str = DEFAULT_LISTEN_ADDRESS) -> None: + """Serve synchronously until termination.""" + try: + asyncio.run(self.serve_async(listen)) + except KeyboardInterrupt: + return + + async def serve_async(self, listen: str = DEFAULT_LISTEN_ADDRESS) -> None: + """Serve asynchronously until termination, then close owned resources.""" + server = _create_grpc_server(self._middleware) + try: + try: + requested_port = _validated_listen_port(listen) + bound_port = server.add_insecure_port(listen) + if bound_port != requested_port: + raise PrivacyGuardError(ErrorCode.SERVER_BIND_FAILED) + _LOGGER.info("privacy_guard_server_bound listen=%r", listen) + await server.start() + except RuntimeError: + raise PrivacyGuardError(ErrorCode.SERVER_BIND_FAILED) from None + await server.wait_for_termination() + finally: + try: + await _stop_grpc_server(server) + finally: + await self._middleware.close() + + +_LOGGER = get_logger(__name__) + + +def _create_grpc_server( + middleware: PrivacyGuardMiddleware, +) -> grpc.aio.Server: + server = grpc.aio.server( + maximum_concurrent_rpcs=MAX_CONCURRENT_RPCS, + options=(("grpc.max_receive_message_length", MAX_RECEIVE_MESSAGE_BYTES),), + ) + pb2_grpc.add_SupervisorMiddlewareServicer_to_server(middleware, server) + return server + + +async def _stop_grpc_server(server: grpc.aio.Server) -> None: + shutdown = asyncio.create_task(server.stop(grace=0)) + try: + await asyncio.shield(shutdown) + except asyncio.CancelledError: + if not shutdown.done(): + await shutdown + raise + + +def _validated_listen_port(listen: str) -> int: + if not isinstance(listen, str): + raise PrivacyGuardError(ErrorCode.SERVER_BIND_FAILED) + if listen.startswith("["): + closing_bracket = listen.rfind("]") + if ( + closing_bracket < 2 + or listen[closing_bracket + 1 : closing_bracket + 2] != ":" + ): + raise PrivacyGuardError(ErrorCode.SERVER_BIND_FAILED) + host = listen[1:closing_bracket] + port_text = listen[closing_bracket + 2 :] + else: + host, separator, port_text = listen.rpartition(":") + if not separator or not host or ":" in host: + raise PrivacyGuardError(ErrorCode.SERVER_BIND_FAILED) + if ( + not host + or not port_text + or len(port_text) > 5 + or not port_text.isascii() + or not port_text.isdecimal() + ): + raise PrivacyGuardError(ErrorCode.SERVER_BIND_FAILED) + port = int(port_text) + if not 1 <= port <= 65_535: + raise PrivacyGuardError(ErrorCode.SERVER_BIND_FAILED) + return port + + +__all__ = [ + "DEFAULT_LISTEN_ADDRESS", + "PrivacyGuardServer", +] diff --git a/projects/privacy-guard/src/privacy_guard/service/servicer.py b/projects/privacy-guard/src/privacy_guard/service/servicer.py new file mode 100644 index 0000000..68b3058 --- /dev/null +++ b/projects/privacy-guard/src/privacy_guard/service/servicer.py @@ -0,0 +1,457 @@ +"""gRPC boundary for active entity-processing policy evaluation.""" + +from __future__ import annotations + +import asyncio +import json +import math +import time +from collections.abc import Callable, Iterable +from concurrent.futures import Future, ThreadPoolExecutor +from threading import Lock +from typing import Never, Protocol, TypedDict, TypeVar + +import grpc +from google.protobuf import json_format +from google.protobuf.message import Message + +from privacy_guard.bindings import supervisor_middleware_pb2 as pb2 +from privacy_guard.bindings import supervisor_middleware_pb2_grpc as pb2_grpc +from privacy_guard.config import PrivacyGuardConfig +from privacy_guard.constants import ( + BLOCK_REASON, + BLOCK_REASON_CODE, + DEFAULT_TIMEOUT_SECONDS, + LIMIT_REASON, + LIMIT_REASON_CODE, + MAX_BODY_BYTES, + MAX_CONCURRENT_PROCESSING, + MAX_PROTO_CONFIG_BYTES, + MAX_PROTO_CONTEXT_BYTES, + MAX_PROTO_FINDING_BYTES, + MAX_PROTO_FINDING_GROUPS, + MAX_PROTO_HEADERS, + MAX_PROTO_HEADERS_BYTES, + MAX_PROTO_TARGET_BYTES, + REASON_CODE_PATTERN, + SERVICE_NAME, + SERVICE_VERSION, +) +from privacy_guard.engines import EngineConfig +from privacy_guard.engines.registry import EngineRegistry +from privacy_guard.errors import ( + EngineRegistryError, + ErrorCode, + ErrorKind, + PrivacyGuardError, +) +from privacy_guard.logging import get_logger +from privacy_guard.request_processor import ( + EntityDetectionSummary, + RequestDecision, + RequestProcessingResult, + RequestProcessor, +) +from privacy_guard.string_validators import validate_bounded_metadata_string +from privacy_guard.timeout import validate_timeout_seconds + + +class PrivacyGuardMiddleware(pb2_grpc.SupervisorMiddlewareServicer): + """Validate, prepare, resolve, and run Privacy Guard policies.""" + + def __init__( + self, + registry: EngineRegistry, + *, + timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, + log_request_content: bool = False, + ) -> None: + if not registry.is_finalized: + raise EngineRegistryError("middleware requires a finalized engine registry") + self._registry = registry + self._policy = _ActivePolicy( + registry, + timeout_seconds=validate_timeout_seconds(timeout_seconds), + log_request_content=log_request_content, + ) + self._processing_slots = asyncio.Semaphore(MAX_CONCURRENT_PROCESSING) + self._processing_executor = ThreadPoolExecutor( + max_workers=MAX_CONCURRENT_PROCESSING, + thread_name_prefix="privacy-guard-processing", + ) + + async def close(self) -> None: + """Wait for in-flight synchronous engines during shutdown.""" + self._processing_executor.shutdown(wait=True, cancel_futures=True) + self._policy.clear() + + async def Describe( + self, + request: object, + context: grpc.aio.ServicerContext[object, pb2.MiddlewareManifest], + ) -> pb2.MiddlewareManifest: + """Advertise the binding and its finalized policy schema.""" + return pb2.MiddlewareManifest( + name=SERVICE_NAME, + service_version=SERVICE_VERSION, + bindings=[ + pb2.MiddlewareBinding( + operation=pb2.SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST, + phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS, + max_body_bytes=MAX_BODY_BYTES, + ) + ], + ) + + async def ValidateConfig( + self, + request: pb2.ValidateConfigRequest, + context: grpc.aio.ServicerContext[ + pb2.ValidateConfigRequest, + pb2.ValidateConfigResponse, + ], + ) -> pb2.ValidateConfigResponse: + """Validate expanded configuration without preparing runtime state.""" + return await self._run_in_worker(lambda: self._validate_config(request)) + + async def EvaluateHttpRequest( + self, + request: pb2.HttpRequestEvaluation, + context: grpc.aio.ServicerContext[ + pb2.HttpRequestEvaluation, + pb2.HttpRequestResult, + ], + ) -> pb2.HttpRequestResult: + """Resolve the prepared config, decode one text, and process it.""" + return await self._evaluate_rpc(request, context) + + def _validate_config( + self, + request: pb2.ValidateConfigRequest, + ) -> pb2.ValidateConfigResponse: + try: + if request.config.ByteSize() > MAX_PROTO_CONFIG_BYTES: + raise PrivacyGuardError(ErrorCode.CONFIG_INVALID) + self._registry.validate_config(_mapping_from_proto(request.config)) + except PrivacyGuardError as error: + return pb2.ValidateConfigResponse(valid=False, reason=str(error)) + except Exception: + error = PrivacyGuardError(ErrorCode.UNEXPECTED_SERVICE_FAILURE) + return pb2.ValidateConfigResponse(valid=False, reason=str(error)) + return pb2.ValidateConfigResponse(valid=True) + + async def _evaluate_rpc( + self, + request: pb2.HttpRequestEvaluation, + context: _AbortContext, + ) -> pb2.HttpRequestResult: + started = time.monotonic() + request_id = _request_id_for_logging(request.context.request_id) + failure: PrivacyGuardError | None = None + action = "error" + finding_count = 0 + try: + response = await self._evaluate_http_request(request) + action = "allow" if response.decision == pb2.DECISION_ALLOW else "deny" + finding_count = sum(finding.count for finding in response.findings) + return response + except PrivacyGuardError as error: + failure = error + except Exception: + failure = PrivacyGuardError(ErrorCode.UNEXPECTED_SERVICE_FAILURE) + finally: + log_extra = _evaluation_log_extra( + request_id=request_id, + started=started, + action=action, + finding_count=finding_count, + failure=failure, + ) + _LOGGER.info( + "privacy_guard_evaluation request_id=%s duration_ms=%.3f " + "action=%s finding_count=%d error_code=%s", + _request_id_for_log_message(log_extra["request_id"]), + log_extra["duration_ms"], + log_extra["action"], + log_extra["finding_count"], + log_extra["error_code"] or "none", + extra=log_extra, + ) + status = ( + grpc.StatusCode.INVALID_ARGUMENT + if failure.kind is ErrorKind.INVALID_INPUT + else grpc.StatusCode.INTERNAL + ) + await context.abort(status, str(failure)) + + async def _evaluate_http_request( + self, + request: pb2.HttpRequestEvaluation, + ) -> pb2.HttpRequestResult: + if request.phase != pb2.SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS: + raise PrivacyGuardError(ErrorCode.REQUEST_PHASE_INVALID) + if len(request.body) > MAX_BODY_BYTES: + raise PrivacyGuardError(ErrorCode.REQUEST_BODY_TOO_LARGE) + _validate_evaluation_envelope(request) + result = await self._run_in_worker( + lambda: self._prepare_and_process(request.config, request.body) + ) + return _result_to_proto(result) + + def _prepare_and_process( + self, + config: Message, + body: bytes, + ) -> RequestProcessingResult: + values = _mapping_from_proto(config) + processor = self._policy.processor_for(values) + if not body: + return RequestProcessingResult(decision=RequestDecision.ALLOW) + try: + text = body.decode("utf-8", errors="strict") + except UnicodeDecodeError: + raise PrivacyGuardError(ErrorCode.BODY_ENCODING_INVALID) from None + return processor.process(text) + + async def _run_in_worker( + self, + operation: Callable[[], _WorkerResultT], + ) -> _WorkerResultT: + """Run one bounded synchronous operation without blocking the event loop.""" + await self._processing_slots.acquire() + try: + worker = self._processing_executor.submit(operation) + future = asyncio.create_task(_await_worker(worker)) + except BaseException: + self._processing_slots.release() + raise + future.add_done_callback(lambda _: self._processing_slots.release()) + return await asyncio.shield(future) + + +class _ActivePolicy: + """Own the process's active policy and its prepared processor.""" + + def __init__( + self, + registry: EngineRegistry, + *, + timeout_seconds: float, + log_request_content: bool, + ) -> None: + self._registry = registry + self._timeout_seconds = timeout_seconds + self._log_request_content = log_request_content + self._config: PrivacyGuardConfig[EngineConfig] | None = None + self._processor: RequestProcessor | None = None + self._lock = Lock() + + def processor_for(self, values: object) -> RequestProcessor: + """Return the processor for the requested policy, activating it if needed.""" + config = self._registry.validate_config(values) + with self._lock: + if config == self._config and self._processor is not None: + return self._processor + processor = self._build_processor(config) + self._config = config + self._processor = processor + return processor + + def _build_processor( + self, + config: PrivacyGuardConfig[EngineConfig], + ) -> RequestProcessor: + stages = tuple( + ( + stage.diagnostic_name(index), + self._registry.create_engine(stage.config), + ) + for index, stage in enumerate( + config.entity_processing.stages, + start=1, + ) + ) + return RequestProcessor( + config, + stages, + timeout_seconds=self._timeout_seconds, + log_request_content=self._log_request_content, + ) + + def clear(self) -> None: + """Release the active policy.""" + with self._lock: + self._config = None + self._processor = None + + +_WorkerResultT = TypeVar("_WorkerResultT") + + +async def _await_worker(worker: Future[_WorkerResultT]) -> _WorkerResultT: + """Bridge a worker without relying on broken cross-thread loop wakeups.""" + while not worker.done(): + await asyncio.sleep(0.001) + return worker.result() + + +class _AbortContext(Protocol): + async def abort(self, code: grpc.StatusCode, details: str) -> Never: ... + + +class _EvaluationLogExtra(TypedDict): + request_id: str + duration_ms: float + action: str + finding_count: int + error_code: str | None + + +def _evaluation_log_extra( + *, + request_id: str, + started: float, + action: str, + finding_count: int, + failure: PrivacyGuardError | None, +) -> _EvaluationLogExtra: + return { + "request_id": request_id, + "duration_ms": round((time.monotonic() - started) * 1000, 3), + "action": action, + "finding_count": finding_count, + "error_code": failure.code.value if failure is not None else None, + } + + +def _request_id_for_logging(request_id: object) -> str: + try: + return validate_bounded_metadata_string(request_id) + except ValueError: + return _INVALID_REQUEST_ID + + +def _request_id_for_log_message(request_id: str) -> str: + return json.dumps(request_id, ensure_ascii=False).replace(" ", r"\u0020") + + +def _mapping_from_proto(config: Message) -> dict[str, object]: + try: + values: object = json_format.MessageToDict(config) + except Exception: + raise PrivacyGuardError(ErrorCode.CONFIG_INVALID) from None + if not isinstance(values, dict) or any(not isinstance(key, str) for key in values): + raise PrivacyGuardError(ErrorCode.CONFIG_INVALID) + return { + key: _normalize_proto_numbers(item) + for key, item in values.items() + if isinstance(key, str) + } + + +def _normalize_proto_numbers(value: object) -> object: + if isinstance(value, float): + if ( + math.isfinite(value) + and value.is_integer() + and -_MAX_PROTO_SAFE_INTEGER <= value <= _MAX_PROTO_SAFE_INTEGER + ): + return int(value) + return value + if isinstance(value, list): + return [_normalize_proto_numbers(item) for item in value] + if isinstance(value, dict): + return {key: _normalize_proto_numbers(item) for key, item in value.items()} + return value + + +def _validate_evaluation_envelope(request: pb2.HttpRequestEvaluation) -> None: + if request.config.ByteSize() > MAX_PROTO_CONFIG_BYTES: + raise PrivacyGuardError(ErrorCode.CONFIG_INVALID) + if ( + request.context.ByteSize() > MAX_PROTO_CONTEXT_BYTES + or request.target.ByteSize() > MAX_PROTO_TARGET_BYTES + or len(request.headers) > MAX_PROTO_HEADERS + or _encoded_headers_size(request.headers) > MAX_PROTO_HEADERS_BYTES + ): + raise PrivacyGuardError(ErrorCode.REQUEST_ENVELOPE_INVALID) + + +def _encoded_headers_size(headers: Iterable[Message]) -> int: + total = 0 + for header in headers: + size = header.ByteSize() + total += 1 + _varint_size(size) + size + return total + + +def _varint_size(value: int) -> int: + size = 1 + while value >= 0x80: + value >>= 7 + size += 1 + return size + + +def _result_to_proto(result: RequestProcessingResult) -> pb2.HttpRequestResult: + findings: list[pb2.Finding] = [] + for detection in result.detection_summaries: + finding = _detection_to_proto(detection) + if finding.ByteSize() > MAX_PROTO_FINDING_BYTES: + return _limit_deny() + findings.append(finding) + if len(findings) > MAX_PROTO_FINDING_GROUPS: + return _limit_deny() + if result.decision is RequestDecision.ALLOW: + replacement = result.replacement_text + replacement_body = ( + replacement.encode("utf-8") if replacement is not None else b"" + ) + if len(replacement_body) > MAX_BODY_BYTES: + return _limit_deny() + return pb2.HttpRequestResult( + decision=pb2.DECISION_ALLOW, + body=replacement_body, + has_body=replacement is not None, + findings=findings, + ) + if result.decision is RequestDecision.DENY: + reason_code = result.reason_code or BLOCK_REASON_CODE + if REASON_CODE_PATTERN.fullmatch(reason_code) is None: + return _limit_deny() + return pb2.HttpRequestResult( + decision=pb2.DECISION_DENY, + reason=LIMIT_REASON if reason_code == LIMIT_REASON_CODE else BLOCK_REASON, + reason_code=reason_code, + findings=findings, + ) + raise PrivacyGuardError(ErrorCode.UNEXPECTED_SERVICE_FAILURE) + + +def _detection_to_proto(detection: EntityDetectionSummary) -> pb2.Finding: + confidence = detection.confidence + confidence_text = confidence.value if confidence is not None else "" + result = pb2.Finding( + type="detected_entity", + label=f"{detection.entity} ({detection.source_stage})", + confidence=confidence_text, + count=detection.count, + ) + return result + + +def _limit_deny() -> pb2.HttpRequestResult: + _LOGGER.info("privacy_guard_processing_limit kind=resource") + return pb2.HttpRequestResult( + decision=pb2.DECISION_DENY, + reason=LIMIT_REASON, + reason_code=LIMIT_REASON_CODE, + ) + + +_LOGGER = get_logger(__name__) +_INVALID_REQUEST_ID = "invalid" +_MAX_PROTO_SAFE_INTEGER = (1 << 53) - 1 + + +__all__ = ["PrivacyGuardMiddleware"] diff --git a/projects/privacy-guard/src/privacy_guard/string_validators.py b/projects/privacy-guard/src/privacy_guard/string_validators.py new file mode 100644 index 0000000..e45c220 --- /dev/null +++ b/projects/privacy-guard/src/privacy_guard/string_validators.py @@ -0,0 +1,48 @@ +"""Reusable string validators and the field types built from them.""" + +from typing import Annotated + +from pydantic import BeforeValidator + +from privacy_guard.constants import MAX_DIAGNOSTIC_TEXT_BYTES + + +def validate_scalar_string(value: object) -> str: + """Validate and return a string containing only Unicode scalar values.""" + if not isinstance(value, str): + raise ValueError("value must be a string") + try: + value.encode("utf-8", errors="strict") + except UnicodeEncodeError: + raise ValueError("string must contain valid Unicode scalar values") + return value + + +def validate_bounded_metadata_string(value: object) -> str: + """Validate and return a non-empty, printable, bounded metadata string.""" + validated = validate_scalar_string(value) + if not validated: + raise ValueError("string must not be empty") + if not validated.isprintable(): + raise ValueError("metadata must contain only printable characters") + if ( + len(validated) > MAX_DIAGNOSTIC_TEXT_BYTES + or len(validated.encode("utf-8")) > MAX_DIAGNOSTIC_TEXT_BYTES + ): + raise ValueError("metadata exceeds the UTF-8 byte limit") + return validated + + +ScalarString = Annotated[str, BeforeValidator(validate_scalar_string)] +BoundedMetadataString = Annotated[ + str, + BeforeValidator(validate_bounded_metadata_string), +] + + +__all__ = [ + "BoundedMetadataString", + "ScalarString", + "validate_bounded_metadata_string", + "validate_scalar_string", +] diff --git a/projects/privacy-guard/src/privacy_guard/timeout.py b/projects/privacy-guard/src/privacy_guard/timeout.py new file mode 100644 index 0000000..763e131 --- /dev/null +++ b/projects/privacy-guard/src/privacy_guard/timeout.py @@ -0,0 +1,66 @@ +"""A shared monotonic timeout for one entity-processing run.""" + +from __future__ import annotations + +import math +from collections.abc import Iterator +from contextlib import contextmanager +from time import monotonic +from typing import Self + +from pydantic import Field + +from privacy_guard.base import StrictDomainModel +from privacy_guard.constants import MAX_TIMEOUT_SECONDS +from privacy_guard.errors import TimeoutExpiredError + + +def validate_timeout_seconds(seconds: object) -> float: + """Return a finite supported processing timeout in seconds.""" + if ( + isinstance(seconds, bool) + or not isinstance(seconds, int | float) + or not math.isfinite(seconds) + or seconds <= 0 + or seconds > MAX_TIMEOUT_SECONDS + ): + raise ValueError( + "timeout seconds must be a finite number greater than 0 and at most " + f"{MAX_TIMEOUT_SECONDS:g}" + ) + return float(seconds) + + +class Timeout(StrictDomainModel): + """An immutable monotonic deadline shared across processing stages.""" + + deadline: float = Field(allow_inf_nan=False) + + @classmethod + def from_seconds(cls, seconds: float) -> Self: + """Create a timeout from a finite, positive bounded duration.""" + return cls(deadline=monotonic() + validate_timeout_seconds(seconds)) + + def remaining_seconds(self) -> float: + """Return the positive duration remaining or raise ``TimeoutExpiredError``.""" + remaining = self.deadline - monotonic() + if remaining <= 0: + raise TimeoutExpiredError + return remaining + + def raise_if_expired(self) -> None: + """Raise ``TimeoutExpiredError`` when no time remains.""" + self.remaining_seconds() + + @contextmanager + def enforce(self) -> Iterator[None]: + """Enforce this deadline around one delegated operation.""" + self.raise_if_expired() + try: + yield + except TimeoutError: + raise TimeoutExpiredError from None + self.raise_if_expired() + + +__all__ = ["Timeout", "validate_timeout_seconds"] diff --git a/projects/privacy-guard/tests/__init__.py b/projects/privacy-guard/tests/__init__.py new file mode 100644 index 0000000..2017165 --- /dev/null +++ b/projects/privacy-guard/tests/__init__.py @@ -0,0 +1 @@ +"""Privacy Guard test package.""" diff --git a/projects/privacy-guard/tests/engines/test_base.py b/projects/privacy-guard/tests/engines/test_base.py new file mode 100644 index 0000000..2dcae3d --- /dev/null +++ b/projects/privacy-guard/tests/engines/test_base.py @@ -0,0 +1,323 @@ +from __future__ import annotations + +from collections.abc import Iterator +from dataclasses import dataclass +from typing import Literal + +import pytest +from pydantic import ValidationError + +from privacy_guard.base import StrictDomainModel +from privacy_guard.constants import MAX_DETECTIONS_PER_STAGE +from privacy_guard.engines import ( + ConfidenceLevel, + EngineConfig, + EngineContractError, + EngineLimitExceededError, + EngineResources, + EntityDetection, + EntityProcessingEngine, + EntityProcessingStrategy, + TextProcessingResult, +) +from privacy_guard.timeout import Timeout + + +class _Replacement(StrictDomainModel): + strategy: Literal["token"] = "token" + + +class _Config(EngineConfig): + engine: Literal["test"] = "test" + replacement: _Replacement | None = None + + +@dataclass(frozen=True) +class _Resources(EngineResources): + prefix: str + + +class _CustomEngine(EntityProcessingEngine[_Config, _Resources]): + supported_strategies = frozenset( + { + EntityProcessingStrategy.DETECT, + EntityProcessingStrategy.REPLACE, + } + ) + + def _run( + self, + text: str, + *, + strategy: EntityProcessingStrategy, + timeout: Timeout, + ) -> TextProcessingResult: + detection = EntityDetection( + entity="token", + start=0, + end=len(text), + confidence=ConfidenceLevel.HIGH, + metadata={"provider": "custom"}, + ) + output = ( + f"{self.resources.prefix}token" + if strategy is EntityProcessingStrategy.REPLACE + else text + ) + return TextProcessingResult(text=output, detections=(detection,)) + + +def test_custom_engine_infers_types_and_needs_no_custom_init() -> None: + config = _Config(replacement=_Replacement()) + resources = _Resources(prefix="[") + + engine = _CustomEngine(config, resources) + + assert _CustomEngine.get_config_type() is _Config + assert _CustomEngine.get_resources_type() is _Resources + assert engine.config is config + assert engine.resources is resources + assert ( + engine.run( + "secret", + strategy=EntityProcessingStrategy.DETECT, + timeout=Timeout.from_seconds(1), + ).text + == "secret" + ) + assert ( + engine.run( + "secret", + strategy=EntityProcessingStrategy.REPLACE, + timeout=Timeout.from_seconds(1), + ).text + == "[token" + ) + + +def test_detection_confidence_and_metadata_are_strict_bounded_values() -> None: + categorical = EntityDetection.model_validate( + { + "entity": "email", + "start": 0, + "end": 1, + "confidence": "high", + "metadata": {"rule": "email.rules[0]"}, + } + ) + assert categorical.confidence is ConfidenceLevel.HIGH + assert type(categorical.metadata).__name__ == "mappingproxy" + with pytest.raises(ValidationError): + EntityDetection.model_validate( + { + "entity": "email", + "start": 0, + "end": 1, + "confidence": 0.25, + } + ) + + +@pytest.mark.parametrize( + "unsafe_value", + [ + "line\nbreak", + "ansi\x1b[31m", + "nul\x00byte", + "right-to-left\u202eoverride", + ], +) +def test_detection_rejects_non_printable_identifiers_and_metadata( + unsafe_value: str, +) -> None: + with pytest.raises(ValidationError): + EntityDetection( + entity=unsafe_value, + start=0, + end=1, + ) + with pytest.raises(ValidationError): + EntityDetection( + entity="token", + start=0, + end=1, + metadata={unsafe_value: "value"}, + ) + with pytest.raises(ValidationError): + EntityDetection( + entity="token", + start=0, + end=1, + metadata={"key": unsafe_value}, + ) + + +def test_detection_accepts_printable_unicode_identifiers_and_metadata() -> None: + detection = EntityDetection( + entity="客户资料", + start=0, + end=1, + metadata={"提供者": "自定义 🛡️"}, + ) + + assert detection.entity == "客户资料" + assert detection.metadata == {"提供者": "自定义 🛡️"} + + +def test_processing_result_bounds_a_lazy_detection_stream() -> None: + produced = 0 + + def detections() -> Iterator[EntityDetection]: + nonlocal produced + for index in range(1_000): + produced += 1 + yield EntityDetection(entity="token", start=index, end=index + 1) + + with pytest.raises(EngineLimitExceededError): + TextProcessingResult.from_detections( + text="x" * 1_000, + detections=detections(), + ) + + assert produced == 257 + + +class _OversizedResultEngine(EntityProcessingEngine[_Config]): + supported_strategies = frozenset({EntityProcessingStrategy.DETECT}) + + def _run( + self, + text: str, + *, + strategy: EntityProcessingStrategy, + timeout: Timeout, + ) -> TextProcessingResult: + del strategy, timeout + return TextProcessingResult( + text=text, + detections=tuple( + EntityDetection(entity="token", start=0, end=1) + for _ in range(MAX_DETECTIONS_PER_STAGE + 1) + ), + ) + + +def test_engine_boundary_bounds_results_built_without_lazy_helper() -> None: + engine = _OversizedResultEngine(_Config(), None) + + with pytest.raises(EngineLimitExceededError): + engine.run( + "text", + strategy=EntityProcessingStrategy.DETECT, + timeout=Timeout.from_seconds(1), + ) + + +class _DetectOnlyEngine(EntityProcessingEngine[_Config]): + supported_strategies = frozenset({EntityProcessingStrategy.DETECT}) + + def _run( + self, + text: str, + *, + strategy: EntityProcessingStrategy, + timeout: Timeout, + ) -> TextProcessingResult: + return TextProcessingResult(text=text, detections=()) + + +def test_detect_only_engine_rejects_replacement_before_running() -> None: + engine = _DetectOnlyEngine(_Config(), None) + + assert _DetectOnlyEngine.get_resources_type() is None + assert engine.resources is None + with pytest.raises(EngineContractError): + engine.run( + "text", + strategy=EntityProcessingStrategy.REPLACE, + timeout=Timeout.from_seconds(1), + ) + + +class _ReplaceOnlyEngine(EntityProcessingEngine[_Config]): + supported_strategies = frozenset({EntityProcessingStrategy.REPLACE}) + + def _run( + self, + text: str, + *, + strategy: EntityProcessingStrategy, + timeout: Timeout, + ) -> TextProcessingResult: + del strategy, timeout + return TextProcessingResult(text=text, detections=()) + + +def test_replace_only_engine_rejects_detection_before_running() -> None: + engine = _ReplaceOnlyEngine(_Config(replacement=_Replacement()), None) + + with pytest.raises(EngineContractError): + engine.run( + "text", + strategy=EntityProcessingStrategy.DETECT, + timeout=Timeout.from_seconds(1), + ) + + +class _MutatingDetectEngine(EntityProcessingEngine[_Config]): + supported_strategies = frozenset( + { + EntityProcessingStrategy.DETECT, + EntityProcessingStrategy.REPLACE, + } + ) + + def _run( + self, + text: str, + *, + strategy: EntityProcessingStrategy, + timeout: Timeout, + ) -> TextProcessingResult: + return TextProcessingResult( + text="changed", + detections=(EntityDetection(entity="token", start=0, end=len(text)),), + ) + + +def test_detection_strategy_rejects_mutated_text() -> None: + engine = _MutatingDetectEngine(_Config(), None) + + with pytest.raises(EngineContractError): + engine.run( + "text", + strategy=EntityProcessingStrategy.DETECT, + timeout=Timeout.from_seconds(1), + ) + + +class _InvalidSpanEngine(EntityProcessingEngine[_Config]): + supported_strategies = frozenset({EntityProcessingStrategy.DETECT}) + + def _run( + self, + text: str, + *, + strategy: EntityProcessingStrategy, + timeout: Timeout, + ) -> TextProcessingResult: + return TextProcessingResult( + text=text, + detections=(EntityDetection(entity="token", start=0, end=len(text) + 1),), + ) + + +def test_engine_boundary_rejects_spans_outside_stage_input() -> None: + engine = _InvalidSpanEngine(_Config(), None) + + with pytest.raises(EngineContractError): + engine.run( + "text", + strategy=EntityProcessingStrategy.DETECT, + timeout=Timeout.from_seconds(1), + ) diff --git a/projects/privacy-guard/tests/engines/test_regex.py b/projects/privacy-guard/tests/engines/test_regex.py new file mode 100644 index 0000000..54ddd3f --- /dev/null +++ b/projects/privacy-guard/tests/engines/test_regex.py @@ -0,0 +1,476 @@ +from __future__ import annotations + +import logging +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier + +import pytest +from pydantic import ValidationError + +import privacy_guard.engines.regex as regex_module +from privacy_guard.engines import ( + EngineConfigurationError, + EngineLimitExceededError, + EntityProcessingStrategy, + RegexEngine, + RegexEngineConfig, + RegexPatternCatalog, +) +from privacy_guard.errors import TimeoutExpiredError +from privacy_guard.timeout import Timeout + + +def _config( + rules: list[dict[str, object]], + *, + replacement: dict[str, object] | None = None, +) -> RegexEngineConfig: + values: dict[str, object] = { + "engine": "regex", + "pattern_catalog": { + "entities": [ + { + "name": "token", + "rules": rules, + } + ] + }, + } + if replacement is not None: + values["replacement"] = replacement + return RegexEngineConfig.model_validate(values) + + +def _run( + config: RegexEngineConfig, + text: str, + strategy: EntityProcessingStrategy = EntityProcessingStrategy.DETECT, +) -> tuple[str, list[tuple[str, int, int, str]]]: + result = RegexEngine(config, None).run( + text, + strategy=strategy, + timeout=Timeout.from_seconds(1), + ) + return result.text, [ + ( + detection.entity, + detection.start, + detection.end, + detection.metadata["rule"], + ) + for detection in result.detections + ] + + +def _catalog(pattern: str) -> RegexPatternCatalog: + return RegexPatternCatalog.model_validate( + { + "entities": [ + { + "name": "token", + "rules": [ + { + "pattern": pattern, + "confidence": "high", + } + ], + } + ] + } + ) + + +def test_detects_overlaps_and_orders_matches_deterministically() -> None: + config = _config( + [ + {"name": "pair", "pattern": "aa", "confidence": "high"}, + {"name": "suffix", "pattern": "a$", "confidence": "medium"}, + ] + ) + + output, detections = _run(config, "aaa") + + assert output == "aaa" + assert detections == [ + ("token", 0, 2, "pair"), + ("token", 1, 3, "pair"), + ("token", 2, 3, "suffix"), + ] + + +def test_optional_names_derive_identity_without_affecting_internal_marker() -> None: + config = _config( + [ + {"name": "same-name", "pattern": "x", "confidence": "high"}, + {"name": "same_name", "pattern": "y", "confidence": "high"}, + {"pattern": "z", "confidence": "high"}, + ] + ) + + _, detections = _run(config, "xyz") + + assert [item[3] for item in detections] == [ + "same-name", + "same_name", + "token.rules[2]", + ] + + +def test_numeric_backreferences_keep_original_group_numbers() -> None: + config = _config([{"pattern": r"(a)\1", "confidence": "high"}]) + + _, detections = _run(config, "aa") + + assert [(item[1], item[2]) for item in detections] == [(0, 2)] + + +def test_explicit_flags_are_supported() -> None: + config = _config( + [ + { + "pattern": "^x.$", + "confidence": "high", + "ignore_case": True, + "multiline": True, + "dot_all": True, + "ascii": True, + } + ] + ) + + _, detections = _run(config, "X\n") + + assert [(item[1], item[2]) for item in detections] == [(0, 2)] + + +@pytest.mark.parametrize( + "pattern", + [ + "", + "x*", + "(?Px)", + "(?i:x)", + ], +) +def test_invalid_patterns_are_rejected_content_safely(pattern: str) -> None: + with pytest.raises(ValidationError) as exception_info: + _config([{"pattern": pattern, "confidence": "high"}]) + + if pattern: + assert pattern not in str(exception_info.value) + + +@pytest.mark.parametrize( + ("pattern", "text"), + [ + ("x|(?=SECRET-zero-width-493)", "SECRET-zero-width-493"), + ("(?=secret)", "secret"), + ("(?<=prefix)", "prefix"), + (r"\b", "secret"), + ("x|(?:y|(?=secret))", "secret"), + ], +) +def test_contextual_zero_width_match_is_invalid_configuration_at_runtime( + pattern: str, + text: str, +) -> None: + config = _config([{"pattern": pattern, "confidence": "high"}]) + engine = RegexEngine(config, None) + + with pytest.raises( + EngineConfigurationError, + match="regex engine configuration is invalid", + ) as exception_info: + engine.run( + text, + strategy=EntityProcessingStrategy.DETECT, + timeout=Timeout.from_seconds(1), + ) + + assert pattern not in str(exception_info.value) + + +@pytest.mark.parametrize( + ("pattern", "text", "expected_span"), + [ + ("(?<=prefix)secret(?=suffix)", "prefixsecretsuffix", (6, 12)), + (r"\bsecret\b", "a secret value", (2, 8)), + ( + r"(? None: + config = _config([{"pattern": pattern, "confidence": "high"}]) + + _, detections = _run(config, text) + + assert [(item[1], item[2]) for item in detections] == [expected_span] + + +def test_duplicate_supplied_names_are_rejected_but_unnamed_rules_are_not() -> None: + with pytest.raises(ValidationError): + _config( + [ + {"name": "duplicate", "pattern": "x", "confidence": "high"}, + {"name": "duplicate", "pattern": "y", "confidence": "high"}, + ] + ) + + config = _config( + [ + {"pattern": "x", "confidence": "high"}, + {"pattern": "y", "confidence": "high"}, + ] + ) + assert len(config.pattern_catalog.entities[0].rules) == 2 + + +def test_replacement_selects_ranked_non_overlapping_winners() -> None: + config = _config( + [ + {"name": "long-low", "pattern": "abc", "confidence": "low"}, + {"name": "short-high", "pattern": "bc", "confidence": "high"}, + ], + replacement={"strategy": "template", "template": "<{entity}>"}, + ) + + output, detections = _run( + config, + "abc", + EntityProcessingStrategy.REPLACE, + ) + + assert output == "a" + assert len(detections) == 2 + + +def test_replacement_requires_an_engine_specific_recipe() -> None: + config = _config([{"pattern": "x", "confidence": "high"}]) + + with pytest.raises(EngineConfigurationError): + _run(config, "x", EntityProcessingStrategy.REPLACE) + + +@pytest.mark.parametrize( + "replacement", + [ + {"strategy": "template", "template": "{unknown}"}, + {"strategy": "template", "template": "{entity.attr}"}, + {"strategy": "template", "template": "{entity!r}"}, + {"strategy": "template", "template": "{entity:>10}"}, + {"strategy": "template", "template": "{"}, + ], +) +def test_template_language_allows_only_literal_text_and_entity( + replacement: dict[str, object], +) -> None: + with pytest.raises(ValidationError): + _config( + [{"pattern": "x", "confidence": "high"}], + replacement=replacement, + ) + + +def test_replacement_size_is_projected_before_rendering( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(regex_module, "MAX_BODY_BYTES", 4) + config = _config( + [{"pattern": "x", "confidence": "high"}], + replacement={"strategy": "template", "template": "[{entity}]"}, + ) + + with pytest.raises(EngineLimitExceededError): + _run(config, "x", EntityProcessingStrategy.REPLACE) + + +def test_pattern_search_has_an_enforceable_timeout() -> None: + config = _config([{"pattern": "(a+)+$", "confidence": "high"}]) + engine = RegexEngine(config, None) + + with pytest.raises(TimeoutExpiredError): + engine.run( + "a" * 100_000 + "!", + strategy=EntityProcessingStrategy.DETECT, + timeout=Timeout.from_seconds(0.001), + ) + + +def test_patterns_compile_during_validation_and_preparation_not_run( + monkeypatch: pytest.MonkeyPatch, +) -> None: + regex_module._clear_compiled_pattern_cache() + compile_count = 0 + original_compile = regex_module.regex.compile + + def recording_compile(pattern: str, flags: int = 0) -> object: + nonlocal compile_count + compile_count += 1 + return original_compile(pattern, flags) + + monkeypatch.setattr(regex_module.regex, "compile", recording_compile) + config = _config([{"pattern": "x", "confidence": "high"}]) + engine = RegexEngine(config, None) + prepared_count = compile_count + + engine.run( + "x", + strategy=EntityProcessingStrategy.DETECT, + timeout=Timeout.from_seconds(1), + ) + + assert prepared_count > 0 + assert compile_count == prepared_count + + +def test_compiled_catalog_cache_evicts_least_recently_used_entry( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + regex_module._clear_compiled_pattern_cache() + catalogs = tuple(_catalog(f"sensitive-pattern-{suffix}") for suffix in "abc") + + try: + first_rules = regex_module._compile_pattern_catalog(catalogs[0]) + entry_weight = regex_module._COMPILED_PATTERN_CACHE[catalogs[0]][1] + monkeypatch.setattr( + regex_module, + "MAX_REGEX_COMPILED_CACHE_WEIGHT_BYTES", + entry_weight * 2, + ) + with caplog.at_level(logging.DEBUG, logger="privacy_guard.engines.regex"): + regex_module._compile_pattern_catalog(catalogs[1]) + assert regex_module._compile_pattern_catalog(catalogs[0]) is first_rules + regex_module._compile_pattern_catalog(catalogs[2]) + + assert tuple(regex_module._COMPILED_PATTERN_CACHE) == ( + catalogs[0], + catalogs[2], + ) + assert regex_module._COMPILED_PATTERN_CACHE_WEIGHT_BYTES == sum( + entry[1] for entry in regex_module._COMPILED_PATTERN_CACHE.values() + ) + assert ( + "privacy_guard_cache_eviction cache=regex_compiled entries=1" in caplog.text + ) + assert "sensitive-pattern" not in caplog.text + finally: + regex_module._clear_compiled_pattern_cache() + + +def test_compiled_catalog_cache_skips_oversized_valid_entry( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + regex_module._clear_compiled_pattern_cache() + monkeypatch.setattr(regex_module, "MAX_REGEX_COMPILED_CACHE_WEIGHT_BYTES", 1) + catalog = _catalog("sensitive-oversized-pattern") + + try: + with caplog.at_level(logging.DEBUG, logger="privacy_guard.engines.regex"): + first = regex_module._compile_pattern_catalog(catalog) + second = regex_module._compile_pattern_catalog(catalog) + + assert first is not second + assert regex_module._COMPILED_PATTERN_CACHE == {} + assert regex_module._COMPILED_PATTERN_CACHE_WEIGHT_BYTES == 0 + assert caplog.text.count("privacy_guard_cache_skip cache=regex_compiled") == 2 + assert "sensitive-oversized-pattern" not in caplog.text + finally: + regex_module._clear_compiled_pattern_cache() + + +def test_compiled_catalog_failure_preserves_existing_weight( + monkeypatch: pytest.MonkeyPatch, +) -> None: + regex_module._clear_compiled_pattern_cache() + retained_catalog = _catalog("retained") + regex_module._compile_pattern_catalog(retained_catalog) + retained_weight = regex_module._COMPILED_PATTERN_CACHE_WEIGHT_BYTES + retained_entries = tuple(regex_module._COMPILED_PATTERN_CACHE) + + def fail_compile(*args: object, **kwargs: object) -> object: + del args, kwargs + raise ValueError("expected test failure") + + monkeypatch.setattr(regex_module, "_compile_rule", fail_compile) + try: + with pytest.raises(ValueError, match="expected test failure"): + regex_module._compile_pattern_catalog(_catalog("failing")) + + assert tuple(regex_module._COMPILED_PATTERN_CACHE) == retained_entries + assert regex_module._COMPILED_PATTERN_CACHE_WEIGHT_BYTES == retained_weight + finally: + regex_module._clear_compiled_pattern_cache() + + +def test_compiled_catalog_same_key_race_accounts_once( + monkeypatch: pytest.MonkeyPatch, +) -> None: + regex_module._clear_compiled_pattern_cache() + worker_count = 4 + workers_ready = Barrier(worker_count) + catalog = _catalog("same-key") + original_compile_rule = regex_module._compile_rule + + def synchronized_compile( + entity: regex_module.RegexEntity, + rule: regex_module.RegexRule, + catalog_index: int, + entity_rule_index: int, + ) -> regex_module._CompiledRule: + workers_ready.wait(timeout=5) + return original_compile_rule( + entity, + rule, + catalog_index, + entity_rule_index, + ) + + monkeypatch.setattr(regex_module, "_compile_rule", synchronized_compile) + try: + with ThreadPoolExecutor(max_workers=worker_count) as executor: + results = tuple( + executor.map( + lambda _: regex_module._compile_pattern_catalog(catalog), + range(worker_count), + ) + ) + + assert all(result is results[0] for result in results) + assert len(regex_module._COMPILED_PATTERN_CACHE) == 1 + assert ( + regex_module._COMPILED_PATTERN_CACHE_WEIGHT_BYTES + == (next(iter(regex_module._COMPILED_PATTERN_CACHE.values()))[1]) + ) + finally: + regex_module._clear_compiled_pattern_cache() + + +def test_regex_engine_is_safe_for_concurrent_runs() -> None: + engine = RegexEngine( + _config([{"pattern": "x", "confidence": "high"}]), + None, + ) + + def run(text: str) -> int: + return len( + engine.run( + text, + strategy=EntityProcessingStrategy.DETECT, + timeout=Timeout.from_seconds(1), + ).detections + ) + + with ThreadPoolExecutor(max_workers=4) as executor: + counts = tuple(executor.map(run, ("x",) * 16)) + + assert counts == (1,) * 16 diff --git a/projects/privacy-guard/tests/engines/test_registry.py b/projects/privacy-guard/tests/engines/test_registry.py new file mode 100644 index 0000000..fc60047 --- /dev/null +++ b/projects/privacy-guard/tests/engines/test_registry.py @@ -0,0 +1,398 @@ +"""Tests for entity-processing engine registration and schema finalization.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +import pytest +from pydantic import field_validator + +from privacy_guard.base import StrictDomainModel +from privacy_guard.engines import ( + EngineConfig, + EngineConfigurationError, + EngineResources, + EntityProcessingEngine, + EntityProcessingStrategy, + RegexEngine, + TextProcessingResult, +) +from privacy_guard.engines.registry import EngineRegistry, create_builtin_registry +from privacy_guard.errors import EngineRegistryError, PrivacyGuardError +from privacy_guard.timeout import Timeout + + +class AcmeReplacement(StrictDomainModel): + strategy: Literal["token"] = "token" + + +class AcmeConfig(EngineConfig): + engine: Literal["acme-pii"] = "acme-pii" + entities: tuple[str, ...] + replacement: AcmeReplacement | None = None + + @field_validator("entities", mode="before") + @classmethod + def _entities_are_a_tuple(cls, value: object) -> object: + if not isinstance(value, list | tuple): + raise ValueError("entities must be a list") + return tuple(value) + + +@dataclass(frozen=True) +class AcmeResources(EngineResources): + prefix: str + + +class AcmeEngine(EntityProcessingEngine[AcmeConfig, AcmeResources]): + supported_strategies = frozenset( + { + EntityProcessingStrategy.DETECT, + EntityProcessingStrategy.REPLACE, + } + ) + + @classmethod + def _validate_run_config( + cls, + config: AcmeConfig, + resources: AcmeResources, + *, + strategy: EntityProcessingStrategy, + ) -> None: + del cls, resources + if strategy is EntityProcessingStrategy.REPLACE and config.replacement is None: + raise EngineConfigurationError("acme replacement configuration is required") + + def _run( + self, + text: str, + *, + strategy: EntityProcessingStrategy, + timeout: Timeout, + ) -> TextProcessingResult: + del strategy, timeout + return TextProcessingResult(text=text, detections=()) + + +class DetectConfig(EngineConfig): + engine: Literal["detect-only"] = "detect-only" + + +class DetectEngine(EntityProcessingEngine[DetectConfig]): + supported_strategies = frozenset({EntityProcessingStrategy.DETECT}) + + def _run( + self, + text: str, + *, + strategy: EntityProcessingStrategy, + timeout: Timeout, + ) -> TextProcessingResult: + del strategy, timeout + return TextProcessingResult(text=text, detections=()) + + +def _acme_values(*, action: str = "detect") -> dict[str, object]: + return { + "entity_processing": { + "stages": [ + { + "config": { + "engine": "acme-pii", + "entities": ["account"], + "replacement": {"strategy": "token"}, + } + } + ] + }, + "on_detection": {"action": action}, + } + + +def test_builtin_registry_contains_the_builtin_regex_engine() -> None: + registry = create_builtin_registry() + + assert registry.is_finalized is True + descriptions = registry.describe_engines() + assert tuple(item.engine_name for item in descriptions) == ("regex",) + description = descriptions[0] + assert description.engine_name == "regex" + assert description.supported_strategies == frozenset( + { + EntityProcessingStrategy.DETECT, + EntityProcessingStrategy.REPLACE, + } + ) + + +def test_registry_can_include_builtin_engines_before_custom_registration() -> None: + registry = EngineRegistry(include_builtin_engines=True) + registry.register(AcmeEngine, resources=AcmeResources(prefix="token")) + registry.finalize() + + assert tuple(item.engine_name for item in registry.describe_engines()) == ( + "regex", + "acme-pii", + ) + + +def test_custom_engine_config_joins_the_exact_discriminated_union() -> None: + resources = AcmeResources(prefix="token") + registry = EngineRegistry(include_builtin_engines=True) + registry.register(AcmeEngine, resources=resources) + registry.finalize() + + config = registry.validate_config(_acme_values(action="replace")) + engine = registry.create_engine(config.entity_processing.stages[0].config) + + assert type(config.entity_processing.stages[0].config) is AcmeConfig + assert type(engine) is AcmeEngine + assert engine.config is config.entity_processing.stages[0].config + assert engine.resources is resources + assert tuple(item.engine_name for item in registry.describe_engines()) == ( + "regex", + "acme-pii", + ) + + +def test_detection_only_engine_is_rejected_for_replace_action() -> None: + registry = EngineRegistry() + registry.register(DetectEngine) + registry.finalize() + values = { + "entity_processing": {"stages": [{"config": {"engine": "detect-only"}}]}, + "on_detection": {"action": "replace"}, + } + + with pytest.raises(PrivacyGuardError): + registry.validate_config(values) + + +def test_engine_owns_strategy_specific_configuration_requirements() -> None: + registry = EngineRegistry() + registry.register(AcmeEngine, resources=AcmeResources(prefix="token")) + registry.finalize() + values = { + "entity_processing": { + "stages": [ + { + "config": { + "engine": "acme-pii", + "entities": ["account"], + } + } + ] + }, + "on_detection": {"action": "replace"}, + } + + with pytest.raises(PrivacyGuardError): + registry.validate_config(values) + + +class ReplaceOnlyConfig(EngineConfig): + engine: Literal["replace-only"] = "replace-only" + + +class ReplaceOnlyEngine(EntityProcessingEngine[ReplaceOnlyConfig]): + supported_strategies = frozenset({EntityProcessingStrategy.REPLACE}) + + def _run( + self, + text: str, + *, + strategy: EntityProcessingStrategy, + timeout: Timeout, + ) -> TextProcessingResult: + del strategy, timeout + return TextProcessingResult(text=text, detections=()) + + +def test_replacement_only_engine_is_rejected_for_detect_action() -> None: + registry = EngineRegistry() + registry.register(ReplaceOnlyEngine) + registry.finalize() + values = { + "entity_processing": { + "stages": [ + { + "config": { + "engine": "replace-only", + } + } + ] + }, + "on_detection": {"action": "detect"}, + } + + with pytest.raises(PrivacyGuardError): + registry.validate_config(values) + + values["on_detection"] = {"action": "replace"} + config = registry.validate_config(values) + + config_type = type(config.entity_processing.stages[0].config) + assert "replacement" not in config_type.model_fields + + +def test_registry_is_frozen_after_finalize_and_finalize_is_idempotent() -> None: + registry = EngineRegistry() + registry.register(RegexEngine) + + assert registry.finalize() is registry + assert registry.finalize() is registry + with pytest.raises(EngineRegistryError): + registry.register(DetectEngine) + + +def test_registry_rejects_duplicate_discriminators_and_resource_mismatch() -> None: + registry = EngineRegistry() + registry.register(AcmeEngine, resources=AcmeResources(prefix="token")) + + with pytest.raises(EngineRegistryError): + registry.register(AcmeEngine, resources=AcmeResources(prefix="other")) + with pytest.raises(EngineRegistryError): + EngineRegistry().register(AcmeEngine) + with pytest.raises(EngineRegistryError, match="must extend EngineResources"): + EngineRegistry().register(AcmeEngine, resources=object()) + with pytest.raises(EngineRegistryError): + EngineRegistry().register(DetectEngine, resources=object()) + + +def _run_without_the_engine_wrapper( + self: DetectEngine, + text: str, + *, + strategy: EntityProcessingStrategy, + timeout: Timeout, +) -> TextProcessingResult: + del self, strategy, timeout + return TextProcessingResult(text=text, detections=()) + + +def _initialize_without_the_engine_constructor( + self: DetectEngine, + config: DetectConfig, + resources: None, +) -> None: + del self, config, resources + + +@pytest.mark.parametrize( + ("method_name", "method", "expected_error"), + [ + ( + "run", + _run_without_the_engine_wrapper, + "engine lifecycle contract requires EntityProcessingEngine.run; " + "implement _run() instead", + ), + ( + "__init__", + _initialize_without_the_engine_constructor, + "engine lifecycle contract requires EntityProcessingEngine.__init__; " + "use _initialize() instead", + ), + ], +) +def test_registry_rejects_direct_and_inherited_lifecycle_overrides( + method_name: str, + method: object, + expected_error: str, +) -> None: + direct_override = type( + "LifecycleOverrideEngine", + (DetectEngine,), + {method_name: method}, + ) + inherited_override = type( + "InheritedOverrideEngine", + (direct_override,), + {}, + ) + + for engine_type in (direct_override, inherited_override): + with pytest.raises(EngineRegistryError) as error: + EngineRegistry().register(engine_type) + + assert str(error.value) == expected_error + + +def test_base_lifecycle_methods_are_final_for_static_feedback() -> None: + assert getattr(EntityProcessingEngine.__init__, "__final__", False) is True + assert getattr(EntityProcessingEngine.run, "__final__", False) is True + + +def test_registry_accepts_base_lifecycle_inherited_through_custom_base() -> None: + intermediate_base = type( + "ValidIntermediateEngineBase", + (DetectEngine,), + {}, + ) + inherited_lifecycle_engine = type( + "InheritedLifecycleEngine", + (intermediate_base,), + {}, + ) + + registry = EngineRegistry() + registry.register(inherited_lifecycle_engine) + + assert inherited_lifecycle_engine.__init__ is EntityProcessingEngine.__init__ + assert inherited_lifecycle_engine.run is EntityProcessingEngine.run + + +@pytest.mark.parametrize( + ("engine_type", "resources"), + [ + (DetectEngine, None), + (AcmeEngine, AcmeResources(prefix="token")), + ], +) +def test_registry_accepts_engines_using_the_base_lifecycle( + engine_type: type[object], + resources: object, +) -> None: + registry = EngineRegistry() + + registry.register(engine_type, resources=resources) + + assert registry.finalize().is_finalized is True + + +def test_describe_does_not_construct_an_engine() -> None: + class CountingEngine(EntityProcessingEngine[DetectConfig]): + supported_strategies = frozenset({EntityProcessingStrategy.DETECT}) + initialized = 0 + + def _initialize(self) -> None: + type(self).initialized += 1 + + def _run( + self, + text: str, + *, + strategy: EntityProcessingStrategy, + timeout: Timeout, + ) -> TextProcessingResult: + del strategy, timeout + return TextProcessingResult(text=text, detections=()) + + registry = EngineRegistry() + registry.register(CountingEngine) + registry.finalize() + + descriptions = registry.describe_engines() + + assert CountingEngine.initialized == 0 + assert descriptions[0].engine_name == "detect-only" + assert descriptions[0].supported_strategies == frozenset( + {EntityProcessingStrategy.DETECT} + ) + + +def test_registry_requires_at_least_one_engine() -> None: + with pytest.raises(EngineRegistryError): + EngineRegistry().finalize() diff --git a/projects/privacy-guard/tests/examples/test_custom_engine.py b/projects/privacy-guard/tests/examples/test_custom_engine.py new file mode 100644 index 0000000..706b7e2 --- /dev/null +++ b/projects/privacy-guard/tests/examples/test_custom_engine.py @@ -0,0 +1,140 @@ +"""End-to-end checks for the custom engine application example.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import yaml + +EXAMPLE_DIRECTORY = Path(__file__).parents[2] / "examples" / "custom-engine" + + +def test_custom_engine_runs_through_the_middleware_boundary() -> None: + probe = r""" +import asyncio +from pathlib import Path + +from google.protobuf import json_format +import yaml + +from privacy_guard.bindings import supervisor_middleware_pb2 as pb2 +from privacy_guard.service.servicer import PrivacyGuardMiddleware +from custom_engine import create_registry + +values = yaml.safe_load(Path("privacy-guard-config.yaml").read_text()) +assert isinstance(values, dict) +config = pb2.HttpRequestEvaluation().config +json_format.ParseDict(values, config) + + +async def evaluate() -> None: + middleware = PrivacyGuardMiddleware(create_registry()) + try: + result = await middleware._evaluate_http_request( + pb2.HttpRequestEvaluation( + phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS, + config=config, + body=b"Discuss Project Cobalt safely.", + ) + ) + finally: + await middleware.close() + + assert result.decision == pb2.DECISION_ALLOW + assert result.has_body is False + assert result.body == b"" + assert len(result.findings) == 1 + assert result.findings[0].label == ( + "confidential-project (project-names)" + ) + + +asyncio.run(evaluate()) +""" + + subprocess.run( + [sys.executable, "-c", probe], + cwd=EXAMPLE_DIRECTORY, + check=True, + ) + + +def test_custom_registry_drives_cli_discovery_and_schema() -> None: + environment = os.environ.copy() + python_path = str(EXAMPLE_DIRECTORY) + existing_python_path = environment.get("PYTHONPATH") + if existing_python_path: + python_path = os.pathsep.join((python_path, existing_python_path)) + environment["PYTHONPATH"] = python_path + command = [ + str(Path(sys.executable).with_name("privacy-guard")), + "--registry-factory", + "custom_engine:create_registry", + ] + + engines = subprocess.run( + [*command, "engines"], + cwd=EXAMPLE_DIRECTORY, + check=True, + capture_output=True, + text=True, + env=environment, + ) + schema = subprocess.run( + [*command, "configuration-schema"], + cwd=EXAMPLE_DIRECTORY, + check=True, + capture_output=True, + text=True, + env=environment, + ) + + assert engines.stdout.startswith("regex\tdetect,replace\t") + assert "keyword-tool\tdetect\t" in engines.stdout + serialized_schema = json.loads(schema.stdout) + assert "RegexEngineConfig" in serialized_schema["$defs"] + assert "KeywordEngineConfig" in serialized_schema["$defs"] + keyword_properties = serialized_schema["$defs"]["KeywordEngineConfig"]["properties"] + assert set(keyword_properties) == { + "engine", + "entity", + "keyword", + } + + +def test_openshell_walkthrough_uses_the_custom_registry_and_current_policy() -> None: + policy = yaml.safe_load((EXAMPLE_DIRECTORY / "policy.yaml").read_text()) + config = yaml.safe_load( + (EXAMPLE_DIRECTORY / "privacy-guard-config.yaml").read_text() + ) + readme = (EXAMPLE_DIRECTORY / "README.md").read_text() + implementation = (EXAMPLE_DIRECTORY / "custom_engine.py").read_text() + + assert isinstance(policy, dict) + assert isinstance(config, dict) + assert not (EXAMPLE_DIRECTORY / "privacy_guard_app.py").exists() + assert "EngineRegistry(include_builtin_engines=True)" in implementation + assert "def create_registry() -> EngineRegistry:" in implementation + middleware_config = policy["network_middlewares"]["privacy_guard_detect"] + assert middleware_config["middleware"] == "privacy-guard-custom-engine" + assert middleware_config["config"] == config + stage_config = config["entity_processing"]["stages"][0]["config"] + assert stage_config["engine"] == "keyword-tool" + assert config["on_detection"]["action"] == "detect" + assert "--registry-factory custom_engine:create_registry" in readme + assert "cd projects/privacy-guard/examples/custom-engine" in readme + assert 'export PYTHONPATH="$PWD${PYTHONPATH:+:$PYTHONPATH}"' in readme + assert "uv run privacy-guard configure-gateway" in readme + assert '--host-ip "$YOUR_HOST_IP"' in readme + assert "--name privacy-guard-custom-engine" in readme + assert "--config gateway.local.toml" in readme + assert 'sed "s/REPLACE_WITH_HOST_IP/' not in readme + assert not (EXAMPLE_DIRECTORY / "gateway.toml").exists() + assert "openshell gateway select openshell" in readme + assert "openshell gateway add" not in readme + assert "OpenShell `v0.0.90`" in readme + assert "transformed:false" in readme diff --git a/projects/privacy-guard/tests/examples/test_regex_engine.py b/projects/privacy-guard/tests/examples/test_regex_engine.py new file mode 100644 index 0000000..96aafc9 --- /dev/null +++ b/projects/privacy-guard/tests/examples/test_regex_engine.py @@ -0,0 +1,112 @@ +"""End-to-end checks for the built-in RegexEngine example.""" + +from __future__ import annotations + +import asyncio +import json +import subprocess +import sys +from pathlib import Path + +import pytest +import yaml +from google.protobuf import json_format + +from privacy_guard.bindings import supervisor_middleware_pb2 as pb2 +from privacy_guard.engines import RegexPatternCatalog +from privacy_guard.engines.registry import create_builtin_registry +from privacy_guard.service.servicer import PrivacyGuardMiddleware + +EXAMPLE_DIRECTORY = Path(__file__).parents[2] / "examples" / "regex-engine" + + +def test_regex_example_runs_through_the_middleware_boundary( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(EXAMPLE_DIRECTORY) + values = yaml.safe_load( + (EXAMPLE_DIRECTORY / "privacy-guard-config.yaml").read_text() + ) + assert isinstance(values, dict) + config = pb2.HttpRequestEvaluation().config + json_format.ParseDict(values, config) + + async def evaluate() -> None: + middleware = PrivacyGuardMiddleware(create_builtin_registry()) + try: + result = await middleware._evaluate_http_request( + pb2.HttpRequestEvaluation( + phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS, + config=config, + body=(b"Contact user@example.com about customer CUST-12345678."), + ) + ) + finally: + await middleware.close() + + assert result.decision == pb2.DECISION_ALLOW + assert result.has_body is True + assert result.body == (b"Contact [email] about customer [customer-id].") + assert {finding.label for finding in result.findings} == { + "email (identifiers)", + "customer-id (identifiers)", + } + + asyncio.run(evaluate()) + + +def test_builtin_registry_drives_documented_cli_discovery_and_schema() -> None: + command = str(Path(sys.executable).with_name("privacy-guard")) + engines = subprocess.run( + [command, "engines"], + cwd=EXAMPLE_DIRECTORY, + check=True, + capture_output=True, + text=True, + ) + schema = subprocess.run( + [command, "configuration-schema"], + cwd=EXAMPLE_DIRECTORY, + check=True, + capture_output=True, + text=True, + ) + + assert engines.stdout.startswith("regex\tdetect,replace\t") + serialized_schema = json.loads(schema.stdout) + assert "RegexEngineConfig" in serialized_schema["$defs"] + assert "RegexPatternCatalog" in serialized_schema["$defs"] + assert "RegexRule" in serialized_schema["$defs"] + assert "RegexReplacement" in serialized_schema["$defs"] + + +def test_regex_walkthrough_uses_current_policy_and_gateway_schema() -> None: + policy = yaml.safe_load((EXAMPLE_DIRECTORY / "policy.yaml").read_text()) + config = yaml.safe_load( + (EXAMPLE_DIRECTORY / "privacy-guard-config.yaml").read_text() + ) + catalog = yaml.safe_load((EXAMPLE_DIRECTORY / "patterns.yaml").read_text()) + readme = (EXAMPLE_DIRECTORY / "README.md").read_text() + + assert isinstance(policy, dict) + assert isinstance(config, dict) + assert isinstance(catalog, dict) + middleware_config = policy["network_middlewares"]["privacy_guard_replace"] + assert middleware_config["middleware"] == "privacy-guard-regex" + assert middleware_config["config"] == config + assert config["on_detection"]["action"] == "replace" + stage_config = config["entity_processing"]["stages"][0]["config"] + assert stage_config["engine"] == "regex" + assert stage_config["pattern_catalog"] == "patterns.yaml" + RegexPatternCatalog.model_validate(catalog) + assert "uv run privacy-guard serve --listen 0.0.0.0:50051" in readme + assert "uv run privacy-guard configure-gateway" in readme + assert '--host-ip "$YOUR_HOST_IP"' in readme + assert "--name privacy-guard-regex" in readme + assert "--config gateway.local.toml" in readme + assert 'sed "s/REPLACE_WITH_HOST_IP/' not in readme + assert not (EXAMPLE_DIRECTORY / "gateway.toml").exists() + assert "openshell gateway select openshell" in readme + assert "openshell gateway add" not in readme + assert "OpenShell `v0.0.90`" in readme + assert "transformed:true" in readme diff --git a/projects/privacy-guard/tests/service/__init__.py b/projects/privacy-guard/tests/service/__init__.py new file mode 100644 index 0000000..c8634e6 --- /dev/null +++ b/projects/privacy-guard/tests/service/__init__.py @@ -0,0 +1 @@ +"""Privacy Guard service tests.""" diff --git a/projects/privacy-guard/tests/service/test_grpc_integration.py b/projects/privacy-guard/tests/service/test_grpc_integration.py new file mode 100644 index 0000000..4113c1e --- /dev/null +++ b/projects/privacy-guard/tests/service/test_grpc_integration.py @@ -0,0 +1,328 @@ +"""Real loopback coverage for the generated OpenShell gRPC service.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Literal + +import grpc +import pytest +from google.protobuf import empty_pb2, json_format, message_factory +from google.protobuf.message import Message +from pydantic import field_validator + +from privacy_guard.base import StrictDomainModel +from privacy_guard.bindings import supervisor_middleware_pb2 as pb2 +from privacy_guard.bindings import supervisor_middleware_pb2_grpc as pb2_grpc +from privacy_guard.engines import ( + EngineConfig, + EntityProcessingEngine, + EntityProcessingStrategy, + TextProcessingResult, +) +from privacy_guard.engines.registry import EngineRegistry, create_builtin_registry +from privacy_guard.errors import PrivacyGuardError +from privacy_guard.service.servicer import PrivacyGuardMiddleware +from privacy_guard.timeout import Timeout + + +def _config( + *, + action: str = "replace", + pattern: str = r"[a-z]+@[a-z]+\.[a-z]+", +) -> pb2.ValidateConfigRequest: + request = pb2.ValidateConfigRequest() + json_format.ParseDict( + { + "entity_processing": { + "stages": [ + { + "name": "identifiers", + "config": { + "engine": "regex", + "pattern_catalog": { + "entities": [ + { + "name": "email", + "rules": [ + { + "pattern": pattern, + "confidence": "high", + } + ], + } + ] + }, + "replacement": { + "strategy": "template", + "template": "[{entity}]", + }, + }, + } + ] + }, + "on_detection": {"action": action}, + }, + request.config, + ) + return request + + +def _config_with_stages(stage_count: int) -> pb2.ValidateConfigRequest: + values = json_format.MessageToDict(_config(action="detect").config) + stage = values["entity_processing"]["stages"][0] + stage.pop("name") + values["entity_processing"]["stages"] = [stage] * stage_count + request = pb2.ValidateConfigRequest() + json_format.ParseDict(values, request.config) + return request + + +def _evaluation( + body: bytes, + *, + action: str = "replace", + phase: pb2.SupervisorMiddlewarePhase = ( + pb2.SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS + ), +) -> pb2.HttpRequestEvaluation: + return pb2.HttpRequestEvaluation( + phase=phase, + context=pb2.RequestContext(request_id="grpc-integration"), + config=_config(action=action).config, + body=body, + ) + + +@asynccontextmanager +async def _running_stub( + middleware: PrivacyGuardMiddleware, +) -> AsyncIterator[pb2_grpc.SupervisorMiddlewareStub]: + server = grpc.aio.server() + pb2_grpc.add_SupervisorMiddlewareServicer_to_server(middleware, server) + port = server.add_insecure_port("127.0.0.1:0") + assert port > 0 + await server.start() + channel = grpc.aio.insecure_channel(f"127.0.0.1:{port}") + try: + yield pb2_grpc.SupervisorMiddlewareStub(channel) + finally: + await channel.close() + await server.stop(grace=0) + await middleware.close() + + +@pytest.mark.asyncio +async def test_generated_stub_round_trip_covers_manifest_config_and_actions() -> None: + middleware = PrivacyGuardMiddleware(create_builtin_registry()) + async with _running_stub(middleware) as stub: + empty_message_type = message_factory.GetMessageClass( + empty_pb2.DESCRIPTOR.message_types_by_name["Empty"] + ) + empty_message: Message = empty_message_type() + manifest = await stub.Describe(empty_message) + valid = await stub.ValidateConfig(_config()) + invalid = await stub.ValidateConfig(pb2.ValidateConfigRequest()) + detected = await stub.EvaluateHttpRequest( + _evaluation(b"contact a@b.com", action="detect") + ) + replaced = await stub.EvaluateHttpRequest(_evaluation(b"contact a@b.com")) + blocked = await stub.EvaluateHttpRequest( + _evaluation(b"contact a@b.com", action="block") + ) + clean = await stub.EvaluateHttpRequest(_evaluation(b"no match", action="block")) + + assert manifest.name == "privacy-guard" + assert len(manifest.bindings) == 1 + assert valid.valid is True + assert invalid.valid is False + assert "config_invalid" in invalid.reason + assert detected.decision == pb2.DECISION_ALLOW + assert detected.has_body is False + assert len(detected.findings) == 1 + assert replaced.decision == pb2.DECISION_ALLOW + assert replaced.has_body is True + assert replaced.body == b"contact [email]" + assert blocked.decision == pb2.DECISION_DENY + assert blocked.reason_code == "privacy_guard_blocked" + assert clean.decision == pb2.DECISION_ALLOW + + +@pytest.mark.asyncio +async def test_generated_stub_maps_invalid_and_internal_failures( + monkeypatch: pytest.MonkeyPatch, +) -> None: + middleware = PrivacyGuardMiddleware(create_builtin_registry()) + async with _running_stub(middleware) as stub: + with pytest.raises(grpc.aio.AioRpcError) as invalid: + await stub.EvaluateHttpRequest( + _evaluation( + b"body", + phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED, + ) + ) + assert invalid.value.code() is grpc.StatusCode.INVALID_ARGUMENT + assert "request_phase_invalid" in (invalid.value.details() or "") + + def fail_unexpectedly(values: object, body: bytes) -> None: + del values, body + raise RuntimeError + + monkeypatch.setattr(middleware, "_prepare_and_process", fail_unexpectedly) + with pytest.raises(grpc.aio.AioRpcError) as internal: + await stub.EvaluateHttpRequest(_evaluation(b"body")) + assert internal.value.code() is grpc.StatusCode.INTERNAL + assert "unexpected_service_failure" in (internal.value.details() or "") + + +@pytest.mark.asyncio +async def test_generated_stub_enforces_ten_stage_limit() -> None: + middleware = PrivacyGuardMiddleware(create_builtin_registry()) + async with _running_stub(middleware) as stub: + exact_config = _config_with_stages(10) + oversized_config = _config_with_stages(11) + exact_validation = await stub.ValidateConfig(exact_config) + oversized_validation = await stub.ValidateConfig(oversized_config) + exact_evaluation = _evaluation(b"no match", action="detect") + exact_evaluation.config.CopyFrom(exact_config.config) + exact_result = await stub.EvaluateHttpRequest(exact_evaluation) + oversized_evaluation = _evaluation(b"no match", action="detect") + oversized_evaluation.config.CopyFrom(oversized_config.config) + with pytest.raises(grpc.aio.AioRpcError) as oversized_result: + await stub.EvaluateHttpRequest(oversized_evaluation) + + assert exact_validation.valid is True + assert oversized_validation.valid is False + assert exact_result.decision == pb2.DECISION_ALLOW + assert oversized_result.value.code() is grpc.StatusCode.INVALID_ARGUMENT + assert "config_invalid" in (oversized_result.value.details() or "") + + +@pytest.mark.asyncio +async def test_generated_stub_maps_contextual_zero_width_to_invalid_config() -> None: + report_pattern = "x|(?=SECRET-zero-width-493)" + config = _config(action="detect", pattern=report_pattern) + evaluation = _evaluation(b"SECRET-zero-width-493", action="detect") + evaluation.config.CopyFrom(config.config) + middleware = PrivacyGuardMiddleware(create_builtin_registry()) + + async with _running_stub(middleware) as stub: + before = await stub.EvaluateHttpRequest( + _evaluation(b"contact a@b.com", action="detect") + ) + validation = await stub.ValidateConfig(config) + with pytest.raises(grpc.aio.AioRpcError) as evaluation_error: + await stub.EvaluateHttpRequest(evaluation) + after = await stub.EvaluateHttpRequest( + _evaluation(b"contact a@b.com", action="detect") + ) + + details = evaluation_error.value.details() or "" + assert len(before.findings) == 1 + assert validation.valid is True + assert evaluation_error.value.code() is grpc.StatusCode.INVALID_ARGUMENT + assert "config_invalid" in details + assert "engine_execution_failed" not in details + assert report_pattern not in details + assert len(after.findings) == 1 + + +class _NumericNestedConfig(StrictDomainModel): + count: int + + +class _NumericEngineConfig(EngineConfig): + engine: Literal["numeric"] = "numeric" + threshold: int + ratio: float + nested: _NumericNestedConfig + values: tuple[int, ...] + + @field_validator("values", mode="before") + @classmethod + def _values_are_a_tuple(cls, value: object) -> object: + if not isinstance(value, list | tuple): + raise ValueError("values must be a list") + return tuple(value) + + +class _NumericEngine(EntityProcessingEngine[_NumericEngineConfig]): + supported_strategies = frozenset({EntityProcessingStrategy.DETECT}) + + def _run( + self, + text: str, + *, + strategy: EntityProcessingStrategy, + timeout: Timeout, + ) -> TextProcessingResult: + del strategy, timeout + return TextProcessingResult(text=text, detections=()) + + +def _numeric_values( + threshold: int | float, + *, + ratio: float = 3.0, +) -> dict[str, object]: + return { + "entity_processing": { + "stages": [ + { + "config": { + "engine": "numeric", + "threshold": threshold, + "ratio": ratio, + "nested": {"count": 4}, + "values": [5, 6], + } + } + ] + }, + "on_detection": {"action": "detect"}, + } + + +def _numeric_request( + threshold: int | float, + *, + ratio: float = 3.0, +) -> pb2.ValidateConfigRequest: + request = pb2.ValidateConfigRequest() + json_format.ParseDict(_numeric_values(threshold, ratio=ratio), request.config) + return request + + +def _numeric_registry() -> EngineRegistry: + registry = EngineRegistry() + registry.register(_NumericEngine) + return registry.finalize() + + +@pytest.mark.asyncio +async def test_generated_stub_normalizes_transport_safe_integral_numbers() -> None: + registry = _numeric_registry() + with pytest.raises(PrivacyGuardError): + registry.validate_config(_numeric_values(3.0)) + + middleware = PrivacyGuardMiddleware(registry) + async with _running_stub(middleware) as stub: + ordinary = await stub.ValidateConfig(_numeric_request(3, ratio=3.5)) + safe_max = await stub.ValidateConfig(_numeric_request((1 << 53) - 1)) + safe_min = await stub.ValidateConfig(_numeric_request(-((1 << 53) - 1))) + non_integral = await stub.ValidateConfig(_numeric_request(3.5)) + beyond_safe = await stub.ValidateConfig(_numeric_request(1 << 53)) + evaluation = pb2.HttpRequestEvaluation( + phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS, + config=_numeric_request(3).config, + body=b"body", + ) + result = await stub.EvaluateHttpRequest(evaluation) + + assert ordinary.valid is True + assert safe_max.valid is True + assert safe_min.valid is True + assert non_integral.valid is False + assert beyond_safe.valid is False + assert result.decision == pb2.DECISION_ALLOW diff --git a/projects/privacy-guard/tests/service/test_server.py b/projects/privacy-guard/tests/service/test_server.py new file mode 100644 index 0000000..3147cfe --- /dev/null +++ b/projects/privacy-guard/tests/service/test_server.py @@ -0,0 +1,406 @@ +"""Programmatic Privacy Guard server lifecycle tests.""" + +from __future__ import annotations + +import asyncio +import logging +import subprocess +import sys + +import grpc +import pytest + +from privacy_guard.constants import MAX_CONCURRENT_RPCS, MAX_RECEIVE_MESSAGE_BYTES +from privacy_guard.engines.registry import EngineRegistry, create_builtin_registry +from privacy_guard.errors import EngineRegistryError, ErrorCode, PrivacyGuardError +from privacy_guard.service import server as server_module +from privacy_guard.service.server import PrivacyGuardServer +from privacy_guard.service.servicer import PrivacyGuardMiddleware + + +class _LifecycleServerFake: + """Minimal async-server fake for lifecycle-only tests.""" + + def __init__( + self, + *, + bound_port: int = 50051, + bind_error: RuntimeError | None = None, + start_error: RuntimeError | None = None, + wait_error: BaseException | None = None, + block_stop: bool = False, + ) -> None: + self.bound_port = bound_port + self.bind_error = bind_error + self.start_error = start_error + self.wait_error = wait_error + self.addresses: list[str] = [] + self.started = False + self.waited = False + self.stop_graces: list[float | None] = [] + self.stop_started = asyncio.Event() + self.stop_release = asyncio.Event() + if not block_stop: + self.stop_release.set() + + def add_insecure_port(self, address: str) -> int: + self.addresses.append(address) + if self.bind_error is not None: + raise self.bind_error + return self.bound_port + + async def start(self) -> None: + if self.start_error is not None: + raise self.start_error + self.started = True + + async def wait_for_termination(self, timeout: float | None = None) -> bool: + del timeout + if self.wait_error is not None: + raise self.wait_error + self.waited = True + return True + + async def stop(self, grace: float | None) -> None: + self.stop_graces.append(grace) + self.stop_started.set() + await self.stop_release.wait() + + +def test_programmatic_server_runs_with_injected_registry_and_default_address( + monkeypatch: pytest.MonkeyPatch, +) -> None: + registry = create_builtin_registry() + served: list[tuple[PrivacyGuardServer, str]] = [] + + async def record_serve(self: PrivacyGuardServer, listen: str) -> None: + served.append((self, listen)) + await self._middleware.close() + + monkeypatch.setattr(PrivacyGuardServer, "serve_async", record_serve) + + server = PrivacyGuardServer( + registry=registry, + timeout_seconds=4.5, + log_request_content=True, + ) + server.serve_sync() + + assert served == [(server, "127.0.0.1:50051")] + assert server._middleware._registry is registry + assert server._middleware._policy._timeout_seconds == 4.5 + assert server._middleware._policy._log_request_content is True + + +def test_programmatic_server_requires_an_explicit_finalized_registry() -> None: + with pytest.raises(EngineRegistryError, match="finalized"): + PrivacyGuardServer(EngineRegistry()) + + +@pytest.mark.parametrize("timeout_seconds", [True, 0, 31, float("inf")]) +def test_programmatic_server_rejects_invalid_processing_timeout( + timeout_seconds: bool | int | float, +) -> None: + with pytest.raises( + ValueError, + match="finite number greater than 0 and at most 30", + ): + PrivacyGuardServer( + create_builtin_registry(), + timeout_seconds=timeout_seconds, + ) + + +def test_synchronous_server_exits_cleanly_after_keyboard_interrupt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + server = PrivacyGuardServer(create_builtin_registry()) + + async def interrupt(self: PrivacyGuardServer, listen: str) -> None: + del self, listen + raise KeyboardInterrupt + + monkeypatch.setattr(PrivacyGuardServer, "serve_async", interrupt) + + server.serve_sync() + asyncio.run(server._middleware.close()) + + +def test_programmatic_server_import_does_not_load_the_cli_framework() -> None: + probe = ( + "import sys; " + "from privacy_guard.service import PrivacyGuardServer; " + "assert PrivacyGuardServer.__name__ == 'PrivacyGuardServer'; " + "assert 'privacy_guard.cli' not in sys.modules; " + "assert 'typer' not in sys.modules" + ) + + subprocess.run([sys.executable, "-c", probe], check=True) + + +def test_engine_import_does_not_load_the_server_transport() -> None: + probe = ( + "import sys; " + "import privacy_guard.engines; " + "assert 'privacy_guard.service' not in sys.modules; " + "assert 'grpc' not in sys.modules" + ) + + subprocess.run([sys.executable, "-c", probe], check=True) + + +def test_server_sets_transport_limits_and_registers_middleware( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_server = object() + server_options: list[tuple[int, tuple[tuple[str, int], ...]]] = [] + registrations: list[tuple[PrivacyGuardMiddleware, object]] = [] + + def fake_server_factory( + *, + maximum_concurrent_rpcs: int, + options: tuple[tuple[str, int], ...], + ) -> object: + server_options.append((maximum_concurrent_rpcs, options)) + return fake_server + + def record_registration( + middleware: PrivacyGuardMiddleware, + server: object, + ) -> None: + registrations.append((middleware, server)) + + middleware = _middleware() + monkeypatch.setattr(grpc.aio, "server", fake_server_factory) + monkeypatch.setattr( + server_module.pb2_grpc, + "add_SupervisorMiddlewareServicer_to_server", + record_registration, + ) + try: + result = server_module._create_grpc_server(middleware) + finally: + asyncio.run(middleware.close()) + + assert result is fake_server + assert server_options == [ + ( + MAX_CONCURRENT_RPCS, + (("grpc.max_receive_message_length", MAX_RECEIVE_MESSAGE_BYTES),), + ) + ] + assert registrations == [(middleware, fake_server)] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("fake_server", "sensitive_address"), + [ + (_LifecycleServerFake(bound_port=0), "invalid-sensitive-listen-8472"), + ( + _LifecycleServerFake( + bind_error=RuntimeError("invalid-sensitive-listen-9472") + ), + "invalid-sensitive-listen-9472", + ), + ], +) +async def test_serve_async_sanitizes_bind_failures_and_closes_resources( + monkeypatch: pytest.MonkeyPatch, + fake_server: _LifecycleServerFake, + sensitive_address: str, +) -> None: + closed: list[PrivacyGuardMiddleware] = [] + + async def record_close(middleware: PrivacyGuardMiddleware) -> None: + closed.append(middleware) + + server = PrivacyGuardServer(create_builtin_registry()) + monkeypatch.setattr(server_module, "_create_grpc_server", lambda _: fake_server) + monkeypatch.setattr(PrivacyGuardMiddleware, "close", record_close) + + with pytest.raises(PrivacyGuardError) as captured: + await server.serve_async(sensitive_address) + + assert captured.value.code is ErrorCode.SERVER_BIND_FAILED + assert captured.value.__cause__ is None + assert sensitive_address not in str(captured.value) + assert fake_server.started is False + assert fake_server.waited is False + assert fake_server.stop_graces == [0] + assert closed == [server._middleware] + + +@pytest.mark.asyncio +async def test_serve_async_starts_waits_and_closes_on_normal_termination( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + fake_server = _LifecycleServerFake(bound_port=50053) + closed: list[PrivacyGuardMiddleware] = [] + + async def record_close(middleware: PrivacyGuardMiddleware) -> None: + closed.append(middleware) + + server = PrivacyGuardServer(create_builtin_registry()) + monkeypatch.setattr(server_module, "_create_grpc_server", lambda _: fake_server) + monkeypatch.setattr(PrivacyGuardMiddleware, "close", record_close) + + with caplog.at_level(logging.INFO, logger="privacy_guard.service.server"): + await server.serve_async("127.0.0.1:50053") + + assert fake_server.addresses == ["127.0.0.1:50053"] + assert fake_server.started is True + assert fake_server.waited is True + assert fake_server.stop_graces == [0] + assert closed == [server._middleware] + assert "privacy_guard_server_bound listen='127.0.0.1:50053'" in caplog.text + + +@pytest.mark.asyncio +async def test_serve_async_propagates_cancellation_after_closing_resources( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_server = _LifecycleServerFake( + bound_port=50054, + wait_error=asyncio.CancelledError(), + ) + closed: list[PrivacyGuardMiddleware] = [] + + async def record_close(middleware: PrivacyGuardMiddleware) -> None: + closed.append(middleware) + + server = PrivacyGuardServer(create_builtin_registry()) + monkeypatch.setattr(server_module, "_create_grpc_server", lambda _: fake_server) + monkeypatch.setattr(PrivacyGuardMiddleware, "close", record_close) + + with pytest.raises(asyncio.CancelledError): + await server.serve_async("127.0.0.1:50054") + + assert fake_server.started is True + assert fake_server.stop_graces == [0] + assert closed == [server._middleware] + + +@pytest.mark.asyncio +async def test_serve_async_preserves_cancellation_during_server_shutdown( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_server = _LifecycleServerFake(bound_port=50055, block_stop=True) + closed: list[PrivacyGuardMiddleware] = [] + + async def record_close(middleware: PrivacyGuardMiddleware) -> None: + closed.append(middleware) + + server = PrivacyGuardServer(create_builtin_registry()) + monkeypatch.setattr(server_module, "_create_grpc_server", lambda _: fake_server) + monkeypatch.setattr(PrivacyGuardMiddleware, "close", record_close) + + serving = asyncio.create_task(server.serve_async("127.0.0.1:50055")) + await fake_server.stop_started.wait() + serving.cancel() + await asyncio.sleep(0) + + assert serving.done() is False + + fake_server.stop_release.set() + with pytest.raises(asyncio.CancelledError): + await serving + + assert fake_server.stop_graces == [0] + assert closed == [server._middleware] + + +@pytest.mark.asyncio +async def test_serve_async_sanitizes_startup_failures_and_closes_resources( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_server = _LifecycleServerFake( + bound_port=50056, + start_error=RuntimeError("startup failed"), + ) + closed: list[PrivacyGuardMiddleware] = [] + + async def record_close(middleware: PrivacyGuardMiddleware) -> None: + closed.append(middleware) + + server = PrivacyGuardServer(create_builtin_registry()) + monkeypatch.setattr(server_module, "_create_grpc_server", lambda _: fake_server) + monkeypatch.setattr(PrivacyGuardMiddleware, "close", record_close) + + with pytest.raises(PrivacyGuardError) as captured: + await server.serve_async("127.0.0.1:50056") + + assert captured.value.code is ErrorCode.SERVER_BIND_FAILED + assert captured.value.__cause__ is None + assert "server.start" in str(captured.value) + assert "startup failed" not in str(captured.value) + assert fake_server.waited is False + assert fake_server.stop_graces == [0] + assert closed == [server._middleware] + + +@pytest.mark.parametrize( + ("listen", "port"), + [ + ("127.0.0.1:1", 1), + ("middleware.local:65535", 65_535), + ("[::1]:50051", 50_051), + ], +) +def test_listen_address_accepts_supported_tcp_forms(listen: str, port: int) -> None: + assert server_module._validated_listen_port(listen) == port + + +@pytest.mark.parametrize( + "listen", + [ + "127.0.0.1:0", + "127.0.0.1:65536", + "127.0.0.1:99999", + "127.0.0.1:-1", + "[::1]", + "::1:50051", + ], +) +def test_listen_address_rejects_invalid_numeric_ports_and_forms( + listen: str, +) -> None: + with pytest.raises(PrivacyGuardError) as captured: + server_module._validated_listen_port(listen) + + assert captured.value.code is ErrorCode.SERVER_BIND_FAILED + + +def test_listen_address_rejects_arbitrarily_long_decimal_port() -> None: + with pytest.raises(PrivacyGuardError) as captured: + server_module._validated_listen_port(f"127.0.0.1:{'9' * 5_000}") + + assert captured.value.code is ErrorCode.SERVER_BIND_FAILED + + +@pytest.mark.asyncio +async def test_serve_async_rejects_mismatched_bound_port( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_server = _LifecycleServerFake(bound_port=34_463) + closed: list[PrivacyGuardMiddleware] = [] + + async def record_close(middleware: PrivacyGuardMiddleware) -> None: + closed.append(middleware) + + server = PrivacyGuardServer(create_builtin_registry()) + monkeypatch.setattr(server_module, "_create_grpc_server", lambda _: fake_server) + monkeypatch.setattr(PrivacyGuardMiddleware, "close", record_close) + + with pytest.raises(PrivacyGuardError) as captured: + await server.serve_async("127.0.0.1:9999") + + assert captured.value.code is ErrorCode.SERVER_BIND_FAILED + assert fake_server.started is False + assert fake_server.stop_graces == [0] + assert closed == [server._middleware] + + +def _middleware() -> PrivacyGuardMiddleware: + return PrivacyGuardMiddleware(create_builtin_registry()) diff --git a/projects/privacy-guard/tests/service/test_servicer.py b/projects/privacy-guard/tests/service/test_servicer.py new file mode 100644 index 0000000..205c7aa --- /dev/null +++ b/projects/privacy-guard/tests/service/test_servicer.py @@ -0,0 +1,813 @@ +"""Service boundary tests over the canonical OpenShell-owned protobuf.""" + +from __future__ import annotations + +import asyncio +import logging +from concurrent.futures import ThreadPoolExecutor +from copy import deepcopy +from threading import Barrier, Event, Lock, get_ident +from typing import Never + +import grpc +import pytest +from google.protobuf import json_format +from google.protobuf.message import Message + +from privacy_guard.bindings import supervisor_middleware_pb2 as pb2 +from privacy_guard.config import PrivacyGuardConfig +from privacy_guard.constants import ( + LIMIT_REASON, + LIMIT_REASON_CODE, + MAX_DIAGNOSTIC_TEXT_BYTES, + MAX_PROTO_CONFIG_BYTES, + MAX_PROTO_CONTEXT_BYTES, + MAX_PROTO_FINDING_BYTES, + MAX_PROTO_HEADERS, + MAX_PROTO_HEADERS_BYTES, + MAX_PROTO_TARGET_BYTES, +) +from privacy_guard.engines import ( + EngineConfig, +) +from privacy_guard.engines import regex as regex_module +from privacy_guard.engines.registry import create_builtin_registry +from privacy_guard.errors import ErrorCode, PrivacyGuardError +from privacy_guard.request_processor import ( + EntityDetectionSummary, + RequestDecision, + RequestProcessingResult, + RequestProcessor, +) +from privacy_guard.service import servicer as servicer_module +from privacy_guard.service.servicer import PrivacyGuardMiddleware + + +def _values( + action: str = "replace", + *, + rules: list[dict[str, object]] | None = None, + stage_count: int = 1, + stage_name: str | None = None, +) -> dict[str, object]: + if rules is None: + rules = [ + { + "pattern": r"[a-z]+@[a-z]+\.[a-z]+", + "confidence": "high", + } + ] + stage: dict[str, object] = { + "config": { + "engine": "regex", + "pattern_catalog": { + "entities": [ + { + "name": "email", + "rules": rules, + } + ] + }, + "replacement": { + "strategy": "template", + "template": "[{entity}]", + }, + } + } + if stage_name is not None: + stage["name"] = stage_name + return { + "entity_processing": {"stages": [deepcopy(stage) for _ in range(stage_count)]}, + "on_detection": {"action": action}, + } + + +def _proto_config(values: dict[str, object]) -> Message: + result = pb2.ValidateConfigRequest().config + json_format.ParseDict(values, result) + return result + + +def _request(body: bytes, *, action: str = "replace") -> pb2.HttpRequestEvaluation: + return pb2.HttpRequestEvaluation( + phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS, + config=_proto_config(_values(action)), + body=body, + ) + + +class _SuccessfulEvaluationContext: + async def abort(self, code: grpc.StatusCode, details: str) -> Never: + del code, details + raise AssertionError("successful evaluation unexpectedly aborted") + + +def test_copied_proto_remains_the_current_openshell_contract() -> None: + evaluation = pb2.HttpRequestEvaluation() + finding = pb2.Finding() + + assert isinstance(evaluation.config, Message) + assert not hasattr(evaluation, "config_fingerprint") + assert not hasattr(finding, "source") + + +def test_validate_config_is_pure_and_reports_invalid_config( + monkeypatch: pytest.MonkeyPatch, +) -> None: + middleware = PrivacyGuardMiddleware(create_builtin_registry()) + active = middleware._policy.processor_for(_values(action="replace")) + processor_build_count = 0 + original_build = servicer_module._ActivePolicy._build_processor + + def record_processor_build( + policy: servicer_module._ActivePolicy, + config: PrivacyGuardConfig[EngineConfig], + ) -> RequestProcessor: + nonlocal processor_build_count + processor_build_count += 1 + return original_build(policy, config) + + monkeypatch.setattr( + servicer_module._ActivePolicy, + "_build_processor", + record_processor_build, + ) + try: + valid = middleware._validate_config( + pb2.ValidateConfigRequest(config=_proto_config(_values("detect"))) + ) + invalid = middleware._validate_config( + pb2.ValidateConfigRequest(config=_proto_config({"on_detection": {}})) + ) + still_active = middleware._policy.processor_for(_values(action="replace")) + finally: + asyncio.run(middleware.close()) + + assert valid.valid is True + assert invalid.valid is False + assert "config_invalid" in invalid.reason + assert still_active is active + assert processor_build_count == 0 + + +def test_validate_config_rejects_oversized_proto_before_registry_validation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + validation_count = 0 + original_validate = servicer_module.EngineRegistry.validate_config + + def record_validation( + registry: servicer_module.EngineRegistry, + values: object, + ) -> PrivacyGuardConfig[EngineConfig]: + nonlocal validation_count + validation_count += 1 + return original_validate(registry, values) + + monkeypatch.setattr( + servicer_module.EngineRegistry, + "validate_config", + record_validation, + ) + exact_config = pb2.ValidateConfigRequest() + json_format.ParseDict({"padding": "x" * 65_515}, exact_config.config) + oversized_config = pb2.ValidateConfigRequest() + json_format.ParseDict({"padding": "x" * 65_516}, oversized_config.config) + assert exact_config.config.ByteSize() == MAX_PROTO_CONFIG_BYTES + assert oversized_config.config.ByteSize() == MAX_PROTO_CONFIG_BYTES + 1 + middleware = PrivacyGuardMiddleware(create_builtin_registry()) + try: + exact = middleware._validate_config(exact_config) + oversized = middleware._validate_config(oversized_config) + finally: + asyncio.run(middleware.close()) + + assert exact.valid is False + assert oversized.valid is False + assert validation_count == 1 + + +@pytest.mark.parametrize( + "unsafe_value", + [ + "line\nbreak", + "ansi\x1b[31m", + "nul\x00byte", + "right-to-left\u202eoverride", + ], +) +def test_validate_config_rejects_non_printable_stage_names( + unsafe_value: str, +) -> None: + registry = create_builtin_registry() + + with pytest.raises(PrivacyGuardError) as captured: + registry.validate_config(_values(stage_name=unsafe_value)) + + assert captured.value.code is ErrorCode.CONFIG_INVALID + + +def test_validate_config_accepts_printable_unicode_stage_names() -> None: + config = create_builtin_registry().validate_config(_values(stage_name="身份检查 🛡️")) + + assert config.entity_processing.stages[0].name == "身份检查 🛡️" + + +def test_evaluation_enforces_exact_encoded_transport_boundaries() -> None: + request = _request(b"") + request.context.request_id = "x" * 4_093 + assert request.context.ByteSize() == MAX_PROTO_CONTEXT_BYTES + servicer_module._validate_evaluation_envelope(request) + request.context.request_id += "x" + assert request.context.ByteSize() == MAX_PROTO_CONTEXT_BYTES + 1 + with pytest.raises(PrivacyGuardError) as context_error: + servicer_module._validate_evaluation_envelope(request) + assert context_error.value.code is ErrorCode.REQUEST_ENVELOPE_INVALID + + request = _request(b"") + request.target.host = "x" * 32_764 + assert request.target.ByteSize() == MAX_PROTO_TARGET_BYTES + servicer_module._validate_evaluation_envelope(request) + request.target.host += "x" + assert request.target.ByteSize() == MAX_PROTO_TARGET_BYTES + 1 + with pytest.raises(PrivacyGuardError) as target_error: + servicer_module._validate_evaluation_envelope(request) + assert target_error.value.code is ErrorCode.REQUEST_ENVELOPE_INVALID + + request = _request(b"") + request.headers.add(name="x", value="x" * 65_525) + assert servicer_module._encoded_headers_size(request.headers) == ( + MAX_PROTO_HEADERS_BYTES + ) + servicer_module._validate_evaluation_envelope(request) + request.headers[0].value += "x" + assert servicer_module._encoded_headers_size(request.headers) == ( + MAX_PROTO_HEADERS_BYTES + 1 + ) + with pytest.raises(PrivacyGuardError) as header_size_error: + servicer_module._validate_evaluation_envelope(request) + assert header_size_error.value.code is ErrorCode.REQUEST_ENVELOPE_INVALID + + request = _request(b"") + for _ in range(MAX_PROTO_HEADERS): + request.headers.add() + servicer_module._validate_evaluation_envelope(request) + request.headers.add() + with pytest.raises(PrivacyGuardError) as header_count_error: + servicer_module._validate_evaluation_envelope(request) + assert header_count_error.value.code is ErrorCode.REQUEST_ENVELOPE_INVALID + + +def test_evaluation_enforces_exact_encoded_config_boundary() -> None: + request = _request(b"") + request.config.Clear() + json_format.ParseDict({"padding": "x" * 65_515}, request.config) + assert request.config.ByteSize() == MAX_PROTO_CONFIG_BYTES + servicer_module._validate_evaluation_envelope(request) + request.config.Clear() + json_format.ParseDict({"padding": "x" * 65_516}, request.config) + assert request.config.ByteSize() == MAX_PROTO_CONFIG_BYTES + 1 + + with pytest.raises(PrivacyGuardError) as captured: + servicer_module._validate_evaluation_envelope(request) + + assert captured.value.code is ErrorCode.CONFIG_INVALID + assert "encoded configuration at or below 64 KiB" in str(captured.value) + + +def test_limit_deny_explains_recovery_options() -> None: + result = servicer_module._result_to_proto( + RequestProcessingResult( + decision=RequestDecision.DENY, + reason_code=LIMIT_REASON_CODE, + ) + ) + + assert result.reason == LIMIT_REASON + assert "Check Privacy Guard logs for the limit kind" in result.reason + assert "Reduce the request or replacement size" in result.reason + assert "simplify the configured stages and rules" in result.reason + assert "--timeout-seconds or PrivacyGuardServer(timeout_seconds=...)" in ( + result.reason + ) + assert "additional headroom for queueing and configuration preparation" in ( + result.reason + ) + + +def test_service_limit_deny_logs_a_content_safe_resource_kind( + caplog: pytest.LogCaptureFixture, +) -> None: + sentinel = "sensitive-finding-value" + with caplog.at_level(logging.INFO, logger="privacy_guard.service.servicer"): + result = servicer_module._result_to_proto( + RequestProcessingResult( + decision=RequestDecision.ALLOW, + detection_summaries=( + EntityDetectionSummary( + entity=sentinel + ("x" * MAX_PROTO_FINDING_BYTES), + source_stage="stage", + count=1, + ), + ), + ) + ) + + assert result.reason_code == LIMIT_REASON_CODE + assert "privacy_guard_processing_limit kind=resource" in caplog.text + assert sentinel not in caplog.text + + +@pytest.mark.parametrize( + "invalid_request_id", + [ + "line\nbreak", + "ansi\x1b[31m", + "nul\x00byte", + "right-to-left\u202eoverride", + "x" * (MAX_DIAGNOSTIC_TEXT_BYTES + 1), + ], +) +def test_evaluation_logs_placeholder_for_invalid_request_id( + caplog: pytest.LogCaptureFixture, + invalid_request_id: str, +) -> None: + async def evaluate() -> pb2.HttpRequestResult: + middleware = PrivacyGuardMiddleware(create_builtin_registry()) + request = _request(b"no match", action="detect") + request.context.request_id = invalid_request_id + try: + return await middleware._evaluate_rpc( + request, + _SuccessfulEvaluationContext(), + ) + finally: + await middleware.close() + + with caplog.at_level(logging.INFO, logger="privacy_guard.service.servicer"): + result = asyncio.run(evaluate()) + + records = [ + record + for record in caplog.records + if record.name == "privacy_guard.service.servicer" + and record.getMessage().startswith("privacy_guard_evaluation ") + ] + assert result.decision == pb2.DECISION_ALLOW + assert len(records) == 1 + assert 'request_id="invalid" ' in records[0].getMessage() + assert records[0].getMessage().isprintable() + assert len(caplog.text.splitlines()) == 1 + + +def test_evaluation_logs_printable_unicode_request_id( + caplog: pytest.LogCaptureFixture, +) -> None: + async def evaluate() -> None: + middleware = PrivacyGuardMiddleware(create_builtin_registry()) + request = _request(b"no match", action="detect") + request.context.request_id = "请求-42 🛡️" + try: + await middleware._evaluate_rpc( + request, + _SuccessfulEvaluationContext(), + ) + finally: + await middleware.close() + + with caplog.at_level(logging.INFO, logger="privacy_guard.service.servicer"): + asyncio.run(evaluate()) + + assert r'request_id="请求-42\u0020🛡️"' in caplog.text + assert len(caplog.text.splitlines()) == 1 + + +def test_evaluation_quotes_request_id_delimiters_in_message_log( + caplog: pytest.LogCaptureFixture, +) -> None: + request_id = 'trusted action=allow error_code="none"' + + async def evaluate() -> None: + middleware = PrivacyGuardMiddleware(create_builtin_registry()) + request = _request(b"no match", action="detect") + request.context.request_id = request_id + try: + await middleware._evaluate_rpc( + request, + _SuccessfulEvaluationContext(), + ) + finally: + await middleware.close() + + with caplog.at_level(logging.INFO, logger="privacy_guard.service.servicer"): + asyncio.run(evaluate()) + + records = [ + record + for record in caplog.records + if record.name == "privacy_guard.service.servicer" + and record.getMessage().startswith("privacy_guard_evaluation ") + ] + assert len(records) == 1 + assert getattr(records[0], "request_id") == request_id + assert ( + r'request_id="trusted\u0020action=allow\u0020error_code=\"none\""' + in records[0].getMessage() + ) + assert records[0].getMessage().count(" action=") == 1 + + +def test_middleware_applies_configured_timeout_to_active_processor() -> None: + middleware = PrivacyGuardMiddleware( + create_builtin_registry(), + timeout_seconds=4.5, + ) + try: + processor = middleware._policy.processor_for(_values()) + finally: + asyncio.run(middleware.close()) + + assert processor._timeout_seconds == 4.5 + + +def test_evaluation_decodes_one_utf8_text_and_encodes_replacement() -> None: + async def evaluate() -> pb2.HttpRequestResult: + middleware = PrivacyGuardMiddleware(create_builtin_registry()) + try: + return await middleware._evaluate_http_request(_request(b"email a@b.com")) + finally: + await middleware.close() + + result = asyncio.run(evaluate()) + + assert result.decision == pb2.DECISION_ALLOW + assert result.has_body is True + assert result.body == b"email [email]" + assert len(result.findings) == 1 + assert result.findings[0].type == "detected_entity" + assert result.findings[0].label == "email (regex[1])" + + +def test_evaluation_prepares_configuration_off_the_event_loop( + monkeypatch: pytest.MonkeyPatch, +) -> None: + event_loop_thread = get_ident() + preparation_threads: list[int] = [] + original_processor_for = servicer_module._ActivePolicy.processor_for + + def record_preparation( + policy: servicer_module._ActivePolicy, + values: object, + ) -> RequestProcessor: + preparation_threads.append(get_ident()) + return original_processor_for(policy, values) + + monkeypatch.setattr( + servicer_module._ActivePolicy, + "processor_for", + record_preparation, + ) + + async def evaluate() -> None: + middleware = PrivacyGuardMiddleware(create_builtin_registry()) + try: + await middleware._evaluate_http_request(_request(b"email a@b.com")) + finally: + await middleware.close() + + asyncio.run(evaluate()) + + assert len(preparation_threads) == 1 + assert preparation_threads[0] != event_loop_thread + + +def test_evaluation_revalidates_configuration_before_reusing_active_processor( + monkeypatch: pytest.MonkeyPatch, +) -> None: + validation_count = 0 + original_validate = servicer_module.EngineRegistry.validate_config + + def record_validation( + registry: servicer_module.EngineRegistry, + values: object, + ) -> PrivacyGuardConfig[EngineConfig]: + nonlocal validation_count + validation_count += 1 + return original_validate(registry, values) + + monkeypatch.setattr( + servicer_module.EngineRegistry, + "validate_config", + record_validation, + ) + + async def evaluate_twice() -> None: + middleware = PrivacyGuardMiddleware(create_builtin_registry()) + try: + request = _request(b"email a@b.com") + await middleware._evaluate_http_request(request) + await middleware._evaluate_http_request(request) + finally: + await middleware.close() + + asyncio.run(evaluate_twice()) + + assert validation_count == 2 + + +def test_active_policy_reuses_only_the_current_configuration() -> None: + policy = servicer_module._ActivePolicy( + create_builtin_registry(), + timeout_seconds=1, + log_request_content=False, + ) + first_values = _values(action="detect") + second_values = _values(action="block") + + first = policy.processor_for(first_values) + same = policy.processor_for(deepcopy(first_values)) + second = policy.processor_for(second_values) + rebuilt_first = policy.processor_for(first_values) + + assert same is first + assert second is not first + assert rebuilt_first is not first + assert rebuilt_first is not second + + +@pytest.mark.parametrize("initial_action", [None, "detect"]) +def test_concurrent_requests_for_the_same_policy_build_once( + monkeypatch: pytest.MonkeyPatch, + initial_action: str | None, +) -> None: + worker_count = 4 + workers_ready = Barrier(worker_count) + build_started = Event() + release_build = Event() + build_count = 0 + build_count_lock = Lock() + original_build = servicer_module._ActivePolicy._build_processor + policy = servicer_module._ActivePolicy( + create_builtin_registry(), + timeout_seconds=1, + log_request_content=False, + ) + initial = ( + policy.processor_for(_values(action=initial_action)) + if initial_action is not None + else None + ) + requested_values = _values( + action="block" if initial_action is not None else "detect" + ) + + def pause_build( + active_policy: servicer_module._ActivePolicy, + config: PrivacyGuardConfig[EngineConfig], + ) -> RequestProcessor: + nonlocal build_count + with build_count_lock: + build_count += 1 + build_started.set() + assert release_build.wait(timeout=5) + return original_build(active_policy, config) + + monkeypatch.setattr( + servicer_module._ActivePolicy, + "_build_processor", + pause_build, + ) + + def resolve_policy() -> RequestProcessor: + workers_ready.wait(timeout=5) + return policy.processor_for(requested_values) + + with ThreadPoolExecutor(max_workers=worker_count) as executor: + futures = tuple(executor.submit(resolve_policy) for _ in range(worker_count)) + assert build_started.wait(timeout=5) + assert all(not future.done() for future in futures) + release_build.set() + processors = tuple(future.result(timeout=5) for future in futures) + + assert build_count == 1 + assert all(processor is processors[0] for processor in processors) + assert processors[0] is not initial + assert policy.processor_for(requested_values) is processors[0] + + +def test_different_policy_updates_are_serialized( + monkeypatch: pytest.MonkeyPatch, +) -> None: + first_build_started = Event() + release_first_build = Event() + second_build_started = Event() + build_actions: list[str] = [] + active_builds = 0 + maximum_active_builds = 0 + build_count_lock = Lock() + original_build = servicer_module._ActivePolicy._build_processor + policy = servicer_module._ActivePolicy( + create_builtin_registry(), + timeout_seconds=1, + log_request_content=False, + ) + initial = policy.processor_for(_values(action="detect")) + + def control_build( + active_policy: servicer_module._ActivePolicy, + config: PrivacyGuardConfig[EngineConfig], + ) -> RequestProcessor: + nonlocal active_builds, maximum_active_builds + action = config.on_detection.action.value + with build_count_lock: + active_builds += 1 + maximum_active_builds = max(maximum_active_builds, active_builds) + build_actions.append(action) + try: + if action == "block": + first_build_started.set() + assert release_first_build.wait(timeout=5) + elif action == "replace": + second_build_started.set() + return original_build(active_policy, config) + finally: + with build_count_lock: + active_builds -= 1 + + monkeypatch.setattr( + servicer_module._ActivePolicy, + "_build_processor", + control_build, + ) + + with ThreadPoolExecutor(max_workers=2) as executor: + first_update = executor.submit(policy.processor_for, _values(action="block")) + assert first_build_started.wait(timeout=5) + second_update = executor.submit( + policy.processor_for, + _values(action="replace"), + ) + assert not second_build_started.wait(timeout=0.1) + release_first_build.set() + first_processor = first_update.result(timeout=5) + second_processor = second_update.result(timeout=5) + + assert second_build_started.is_set() + assert build_actions == ["block", "replace"] + assert maximum_active_builds == 1 + assert first_processor is not initial + assert second_processor is not first_processor + assert policy.processor_for(_values(action="replace")) is second_processor + + +def test_failed_policy_update_preserves_the_active_processor( + monkeypatch: pytest.MonkeyPatch, +) -> None: + failure = PrivacyGuardError(ErrorCode.UNEXPECTED_SERVICE_FAILURE) + original_build = servicer_module._ActivePolicy._build_processor + policy = servicer_module._ActivePolicy( + create_builtin_registry(), + timeout_seconds=1, + log_request_content=False, + ) + active_values = _values(action="detect") + update_values = _values(action="block") + active = policy.processor_for(active_values) + + def fail_update( + active_policy: servicer_module._ActivePolicy, + config: PrivacyGuardConfig[EngineConfig], + ) -> RequestProcessor: + if config.on_detection.action.value == "block": + raise failure + return original_build(active_policy, config) + + monkeypatch.setattr( + servicer_module._ActivePolicy, + "_build_processor", + fail_update, + ) + + with pytest.raises(PrivacyGuardError) as captured: + policy.processor_for(update_values) + + assert captured.value is failure + assert policy.processor_for(active_values) is active + + monkeypatch.setattr( + servicer_module._ActivePolicy, + "_build_processor", + original_build, + ) + updated = policy.processor_for(update_values) + + assert updated is not active + assert policy.processor_for(update_values) is updated + + +def test_compiled_cache_eviction_does_not_invalidate_active_processor( + monkeypatch: pytest.MonkeyPatch, +) -> None: + regex_module._clear_compiled_pattern_cache() + registry = create_builtin_registry() + policy = servicer_module._ActivePolicy( + registry, + timeout_seconds=1, + log_request_content=False, + ) + processor_values = _values( + "detect", + rules=[{"pattern": "aaa", "confidence": "high"}], + ) + validation_values = _values( + "detect", + rules=[{"pattern": "bbb", "confidence": "high"}], + ) + + try: + processor = policy.processor_for(processor_values) + entry_weight = regex_module._COMPILED_PATTERN_CACHE_WEIGHT_BYTES + monkeypatch.setattr( + regex_module, + "MAX_REGEX_COMPILED_CACHE_WEIGHT_BYTES", + entry_weight, + ) + + registry.validate_config(validation_values) + + result = processor.process("aaa") + assert len(result.detection_summaries) == 1 + assert policy.processor_for(processor_values) is processor + assert regex_module._COMPILED_PATTERN_CACHE_WEIGHT_BYTES <= entry_weight + finally: + policy.clear() + regex_module._clear_compiled_pattern_cache() + + +def test_middleware_shutdown_clears_active_policy() -> None: + regex_module._clear_compiled_pattern_cache() + middleware = PrivacyGuardMiddleware(create_builtin_registry()) + try: + middleware._policy.processor_for(_values("detect")) + + asyncio.run(middleware.close()) + + assert middleware._policy._config is None + assert middleware._policy._processor is None + finally: + middleware._policy.clear() + regex_module._clear_compiled_pattern_cache() + + +def test_oversized_stage_list_fails_before_engine_construction( + monkeypatch: pytest.MonkeyPatch, +) -> None: + values = _values(action="detect", stage_count=10_000) + + def unexpected_call(*args: object, **kwargs: object) -> object: + del args, kwargs + raise AssertionError("oversized stage list reached preparation") + + monkeypatch.setattr( + servicer_module.EngineRegistry, + "create_engine", + unexpected_call, + ) + middleware = PrivacyGuardMiddleware(create_builtin_registry()) + try: + with pytest.raises(PrivacyGuardError) as captured: + middleware._policy.processor_for(values) + finally: + asyncio.run(middleware.close()) + + assert captured.value.code is ErrorCode.CONFIG_INVALID + + +def test_invalid_utf8_fails_before_invoking_an_engine() -> None: + async def evaluate() -> None: + middleware = PrivacyGuardMiddleware(create_builtin_registry()) + try: + with pytest.raises(PrivacyGuardError) as captured: + await middleware._evaluate_http_request(_request(b"\xff")) + assert captured.value.code is ErrorCode.BODY_ENCODING_INVALID + finally: + await middleware.close() + + asyncio.run(evaluate()) + + +def test_detect_returns_no_body_mutation() -> None: + async def evaluate() -> pb2.HttpRequestResult: + middleware = PrivacyGuardMiddleware(create_builtin_registry()) + try: + return await middleware._evaluate_http_request( + _request(b"a@b.com", action="detect") + ) + finally: + await middleware.close() + + result = asyncio.run(evaluate()) + + assert result.decision == pb2.DECISION_ALLOW + assert result.has_body is False + assert result.body == b"" diff --git a/projects/privacy-guard/tests/test_cli.py b/projects/privacy-guard/tests/test_cli.py new file mode 100644 index 0000000..e22ef88 --- /dev/null +++ b/projects/privacy-guard/tests/test_cli.py @@ -0,0 +1,361 @@ +"""Privacy Guard command-line application tests.""" + +from __future__ import annotations + +import json +import re +from collections.abc import Iterator +from importlib.metadata import entry_points +from pathlib import Path +from types import SimpleNamespace + +import pytest +from typer.testing import CliRunner, Result + +from privacy_guard import cli as cli_module +from privacy_guard.cli import app +from privacy_guard.engines.registry import EngineRegistry, create_builtin_registry +from privacy_guard.errors import ErrorCode, PrivacyGuardError +from privacy_guard.logging import reset_logging +from privacy_guard.service.server import PrivacyGuardServer + + +@pytest.fixture(autouse=True) +def _reset_cli_logging() -> Iterator[None]: + yield + reset_logging() + + +def test_cli_help_exposes_server_and_discovery_commands() -> None: + result = CliRunner().invoke(app, ["--help"]) + + assert result.exit_code == 0 + output = _plain_output(result) + assert "serve" in output + assert "configuration-schema" in output + assert "configure-gateway" in output + assert "engines" in output + assert "--debug" in output + assert "--debug-log-content" in output + assert "--registry-factory" in output + assert "--config" not in output + assert "--profile" not in output + assert "--scanner-name" not in output + + +def test_cli_configure_gateway_help_requires_an_explicit_host_ip() -> None: + result = CliRunner().invoke( + app, + ["configure-gateway", "--help"], + terminal_width=240, + ) + + assert result.exit_code == 0 + output = _normalized_output(result) + assert "--host-ip" in output + assert "required" in output.lower() + assert "Non-loopback IPv4" in output + assert "$OPENSHELL_GATEWAY_CONFIG" in output + assert "$XDG_CONFIG_HOME/openshell" in output + assert "1-128 ASCII bytes" in output + assert "restart the OpenShell gateway" not in output + + +def test_cli_configure_gateway_updates_the_default_xdg_config( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + + result = CliRunner().invoke( + app, + ["configure-gateway", "--host-ip", "192.168.1.20"], + ) + + assert result.exit_code == 0 + config_path = tmp_path / "openshell" / "gateway.toml" + assert config_path.exists() + output = _plain_output(result) + assert f"Created {config_path}" in output + assert "Registered privacy-guard at http://192.168.1.20:50051" in output + assert "start Privacy Guard, then restart the OpenShell gateway" in output + + +@pytest.mark.parametrize("host_ip", ["host.openshell.internal", "127.0.0.1", "0.0.0.0"]) +def test_cli_configure_gateway_rejects_unusable_host_ip(host_ip: str) -> None: + result = CliRunner().invoke( + app, + ["configure-gateway", "--host-ip", host_ip], + terminal_width=240, + ) + + assert result.exit_code == 2 + output = _normalized_output(result) + assert "--host-ip" in output + assert "IPv4 address" in output + + +@pytest.mark.parametrize( + "name", + [ + "a" * 129, + "privacy guard", + "openshell/privacy-guard", + ], +) +def test_cli_configure_gateway_rejects_invalid_registration_name( + name: str, +) -> None: + result = CliRunner().invoke( + app, + [ + "configure-gateway", + "--host-ip", + "192.168.1.20", + "--name", + name, + ], + terminal_width=240, + ) + + assert result.exit_code == 2 + assert "--name" in _normalized_output(result) + + +def test_cli_configure_gateway_reports_invalid_existing_config( + tmp_path: Path, +) -> None: + path = tmp_path / "gateway.toml" + path.write_text("not valid TOML") + + result = CliRunner().invoke( + app, + [ + "configure-gateway", + "--host-ip", + "192.168.1.20", + "--config", + str(path), + ], + ) + + assert result.exit_code == 1 + output = _plain_output(result) + assert "Could not configure the OpenShell gateway" in output + assert "not valid TOML" in output + assert path.read_text() == "not valid TOML" + + +def test_console_script_targets_the_cli_module() -> None: + console_script = next( + entry_point + for entry_point in entry_points(group="console_scripts") + if entry_point.name == "privacy-guard" + ) + + assert console_script.value == "privacy_guard.cli:app" + + +def test_cli_serve_help_explains_the_processing_timeout() -> None: + result = CliRunner().invoke(app, ["serve", "--help"]) + + assert result.exit_code == 0 + output = _normalized_output(result) + assert "--timeout-seconds" in output + assert "shared by all processing stages" in output + assert "at most 30" in output + + +def test_cli_engines_describes_the_installed_engine() -> None: + result = CliRunner().invoke(app, ["engines"]) + + assert result.exit_code == 0 + assert result.output.startswith("regex\tdetect,replace\t") + description = ( + "Detect every regex match, including matches that share input characters" + ) + assert description in result.output + + +def test_cli_configuration_schema_prints_finalized_policy_schema() -> None: + result = CliRunner().invoke(app, ["configuration-schema"]) + + assert result.exit_code == 0 + schema = json.loads(result.output) + serialized = json.dumps(schema, sort_keys=True) + assert '"propertyName": "engine"' in serialized + assert '"regex"' in serialized + assert '"on_detection"' in serialized + + +def test_cli_loads_one_finalized_operator_registry( + monkeypatch: pytest.MonkeyPatch, +) -> None: + registry = create_builtin_registry() + factory_calls = 0 + + def create_registry() -> EngineRegistry: + nonlocal factory_calls + factory_calls += 1 + return registry + + monkeypatch.setattr( + cli_module.importlib, + "import_module", + lambda module_name: ( + SimpleNamespace(create_registry=create_registry) + if module_name == "operator_engines" + else None + ), + ) + + result = CliRunner().invoke( + app, + ["--registry-factory", "operator_engines:create_registry", "engines"], + ) + + assert result.exit_code == 0 + assert factory_calls == 1 + assert result.output.startswith("regex\tdetect,replace\t") + + +@pytest.mark.parametrize( + ("factory_reference", "reason"), + [ + ("missing-separator", "my_engines:create_registry"), + ("operator_engines:missing", "Verify the module:factory reference"), + ("operator_engines:not_callable", "Export a callable"), + ("operator_engines:failed", "Run the factory directly"), + ("operator_engines:wrong_type", "Return an EngineRegistry"), + ("operator_engines:unfinished", "Call finalize()"), + ], +) +def test_cli_rejects_invalid_registry_factories( + monkeypatch: pytest.MonkeyPatch, + factory_reference: str, + reason: str, +) -> None: + def fail() -> EngineRegistry: + raise RuntimeError("sensitive factory failure") + + module = SimpleNamespace( + not_callable=object(), + failed=fail, + wrong_type=lambda: object(), + unfinished=lambda: EngineRegistry(), + ) + monkeypatch.setattr( + cli_module.importlib, + "import_module", + lambda _: module, + ) + + result = CliRunner().invoke( + app, + ["--registry-factory", factory_reference, "engines"], + terminal_width=240, + ) + + assert result.exit_code == 2 + assert reason in _normalized_output(result) + assert "sensitive factory failure" not in _plain_output(result) + + +def test_cli_explains_registry_module_import_failures_without_leaking_details( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def fail_import(_: str) -> object: + raise RuntimeError("sensitive import failure") + + monkeypatch.setattr(cli_module.importlib, "import_module", fail_import) + + result = CliRunner().invoke( + app, + ["--registry-factory", "operator_engines:create_registry", "engines"], + terminal_width=240, + ) + + assert result.exit_code == 2 + output = _normalized_output(result) + assert "Registry module could not be imported" in output + assert "import the module directly with content-safe diagnostics" in output + assert "sensitive import failure" not in _plain_output(result) + + +def test_cli_serve_adapts_operational_options_to_the_programmatic_server( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[tuple[str, float, bool]] = [] + + def record_serve_sync(self: PrivacyGuardServer, listen: str) -> None: + calls.append( + ( + listen, + self._middleware._policy._timeout_seconds, + self._middleware._policy._log_request_content, + ) + ) + + monkeypatch.setattr(PrivacyGuardServer, "serve_sync", record_serve_sync) + + result = CliRunner().invoke( + app, + [ + "--debug-log-content", + "serve", + "--listen", + "127.0.0.1:50052", + "--timeout-seconds", + "4.5", + ], + ) + + assert result.exit_code == 0 + assert calls == [("127.0.0.1:50052", 4.5, True)] + assert "privacy_guard_request_content_logging_enabled" in _plain_output(result) + + +def test_cli_serve_prints_cataloged_startup_errors_without_a_traceback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def fail_safely(_: PrivacyGuardServer, listen: str) -> None: + del listen + raise PrivacyGuardError(ErrorCode.SERVER_BIND_FAILED) + + monkeypatch.setattr(PrivacyGuardServer, "serve_sync", fail_safely) + + result = CliRunner().invoke( + app, + ["serve", "--listen", "sensitive-listen-address"], + ) + + assert result.exit_code == 1 + assert "[server_bind_failed]" in result.output + assert "Choose an available listen address and port, then retry" in result.output + assert "sensitive-listen-address" not in result.output + assert "Traceback" not in result.output + + +@pytest.mark.parametrize("timeout_seconds", ["0", "31", "nan"]) +def test_cli_rejects_invalid_processing_timeout(timeout_seconds: str) -> None: + result = CliRunner().invoke( + app, + ["serve", "--timeout-seconds", timeout_seconds], + terminal_width=240, + ) + + assert result.exit_code == 2 + output = _normalized_output(result) + assert "--timeout-seconds" in output + assert "greater than 0 and at most 30" in output + + +def _normalized_output(result: Result) -> str: + return " ".join(_plain_output(result).replace("│", " ").split()) + + +def _plain_output(result: Result) -> str: + return _ANSI_STYLE_PATTERN.sub("", result.output) + + +_ANSI_STYLE_PATTERN = re.compile(r"\x1b\[[0-9;]*m") diff --git a/projects/privacy-guard/tests/test_config.py b/projects/privacy-guard/tests/test_config.py new file mode 100644 index 0000000..58b936d --- /dev/null +++ b/projects/privacy-guard/tests/test_config.py @@ -0,0 +1,558 @@ +from __future__ import annotations + +import os +import subprocess +import sys +from collections.abc import Callable +from copy import deepcopy +from pathlib import Path + +import pytest +import yaml +from pydantic import ValidationError + +import privacy_guard.engines.regex as regex_module +from privacy_guard.config import ( + PolicyAction, +) +from privacy_guard.engines import ( + RegexEngine, + RegexEngineConfig, + RegexPatternCatalog, +) +from privacy_guard.engines.registry import EngineRegistry +from privacy_guard.errors import ErrorCode, PrivacyGuardError + + +def _registry() -> EngineRegistry: + registry = EngineRegistry() + registry.register(RegexEngine) + registry.finalize() + return registry + + +def _config( + *, + action: str = "detect", + replacement: dict[str, object] | None = None, + stage_name: str | None = None, +): + engine_config = { + "engine": "regex", + "pattern_catalog": { + "entities": [ + { + "name": "email", + "rules": [ + { + "pattern": r"\buser@example\.com\b", + "confidence": "high", + } + ], + } + ] + }, + } + if replacement is not None: + engine_config["replacement"] = replacement + stage = {"config": engine_config} + if stage_name is not None: + stage["name"] = stage_name + return { + "entity_processing": {"stages": [stage]}, + "on_detection": {"action": action}, + } + + +@pytest.mark.parametrize("action", list(PolicyAction)) +def test_policy_action_uses_detect_block_replace(action: PolicyAction) -> None: + replacement: dict[str, object] | None = ( + {"strategy": "template", "template": "[{entity}]"} + if action is PolicyAction.REPLACE + else None + ) + config = _registry().validate_config( + _config(action=action.value, replacement=replacement) + ) + + assert config.on_detection.action is action + assert [item.value for item in PolicyAction] == ["detect", "block", "replace"] + + +def test_known_discriminator_constructs_the_exact_engine_config() -> None: + config = _registry().validate_config(_config()) + stage = config.entity_processing.stages[0] + + assert type(stage.config) is RegexEngineConfig + assert type(stage.config.pattern_catalog) is RegexPatternCatalog + assert stage.config.pattern_catalog.entities[0].rules[0].name is None + assert stage.diagnostic_name(1) == "regex[1]" + + +def test_policy_accepts_ten_stages_and_rejects_eleven() -> None: + registry = _registry() + exact = _config() + stage = deepcopy(exact["entity_processing"]["stages"][0]) + exact["entity_processing"]["stages"] = [deepcopy(stage) for _ in range(10)] + oversized = deepcopy(exact) + oversized["entity_processing"]["stages"].append(deepcopy(stage)) + + parsed = registry.validate_config(exact) + + assert len(parsed.entity_processing.stages) == 10 + with pytest.raises(PrivacyGuardError) as captured: + registry.validate_config(oversized) + assert captured.value.code is ErrorCode.CONFIG_INVALID + + +def test_explicit_stage_name_is_the_diagnostic_source() -> None: + config = _registry().validate_config(_config(stage_name="credentials")) + + assert config.entity_processing.stages[0].diagnostic_name(1) == "credentials" + + +def test_discriminated_union_round_trip_preserves_concrete_fields() -> None: + registry = _registry() + parsed = registry.validate_config( + _config( + action="replace", + replacement={"strategy": "template", "template": "[{entity}]"}, + ) + ) + serialized = parsed.model_dump(mode="json") + reparsed = registry.validate_config(serialized) + + assert type(reparsed.entity_processing.stages[0].config) is RegexEngineConfig + assert reparsed == parsed + assert serialized["entity_processing"]["stages"][0]["config"]["engine"] == "regex" + assert ( + serialized["entity_processing"]["stages"][0]["config"]["replacement"][ + "strategy" + ] + == "template" + ) + + +def test_generated_schema_declares_the_engine_discriminator() -> None: + schema = _registry().configuration_json_schema() + definitions = _required_dict(schema, "$defs") + stage_definition = next( + definition + for name, definition in definitions.items() + if isinstance(name, str) and name.startswith("EntityProcessingStage") + ) + properties = _required_dict(stage_definition, "properties") + config_schema = _required_dict(properties, "config") + + assert config_schema["discriminator"] == { + "mapping": {"regex": "#/$defs/RegexEngineConfig"}, + "propertyName": "engine", + } + + +def _required_dict(mapping: object, key: str): + assert isinstance(mapping, dict) + value = mapping.get(key) + assert isinstance(value, dict) + return value + + +def test_catalog_file_and_inline_catalog_produce_the_same_config( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + registry = _registry() + inline_values = _config() + file_values = deepcopy(inline_values) + inline_catalog = file_values["entity_processing"]["stages"][0]["config"][ + "pattern_catalog" + ] + (tmp_path / "patterns.yaml").write_text( + yaml.safe_dump(inline_catalog), + encoding="utf-8", + ) + file_values["entity_processing"]["stages"][0]["config"]["pattern_catalog"] = ( + "patterns.yaml" + ) + monkeypatch.chdir(tmp_path) + + inline_config = registry.validate_config(inline_values) + file_config = registry.validate_config(file_values) + + assert file_config == inline_config + serialized_catalog = file_config.model_dump(mode="json")["entity_processing"][ + "stages" + ][0]["config"]["pattern_catalog"] + inline_serialized_catalog = inline_config.model_dump(mode="json")[ + "entity_processing" + ]["stages"][0]["config"]["pattern_catalog"] + assert serialized_catalog == inline_serialized_catalog + assert isinstance(serialized_catalog, dict) + + +def test_catalog_file_change_produces_a_different_validated_config( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + catalog_path = tmp_path / "patterns.yaml" + values = _config() + catalog = values["entity_processing"]["stages"][0]["config"]["pattern_catalog"] + catalog_path.write_text(yaml.safe_dump(catalog), encoding="utf-8") + values["entity_processing"]["stages"][0]["config"]["pattern_catalog"] = ( + "patterns.yaml" + ) + monkeypatch.chdir(tmp_path) + registry = _registry() + first = registry.validate_config(values) + + catalog["entities"][0]["rules"][0]["confidence"] = "low" + catalog_path.write_text(yaml.safe_dump(catalog), encoding="utf-8") + second = registry.validate_config(values) + + assert first != second + + +def test_equivalent_catalogs_reuse_compiled_regex_rules( + monkeypatch: pytest.MonkeyPatch, +) -> None: + regex_module._clear_compiled_pattern_cache() + original_compile_rule = regex_module._compile_rule + compile_calls = 0 + + def record_compile_rule( + entity: regex_module.RegexEntity, + rule: regex_module.RegexRule, + catalog_index: int, + entity_rule_index: int, + ) -> regex_module._CompiledRule: + nonlocal compile_calls + compile_calls += 1 + return original_compile_rule( + entity, + rule, + catalog_index, + entity_rule_index, + ) + + monkeypatch.setattr(regex_module, "_compile_rule", record_compile_rule) + registry = _registry() + first = registry.validate_config(_config()) + registry.create_engine(first.entity_processing.stages[0].config) + second = registry.validate_config(deepcopy(_config())) + registry.create_engine(second.entity_processing.stages[0].config) + changed_values = deepcopy(_config()) + changed_values["entity_processing"]["stages"][0]["config"]["pattern_catalog"][ + "entities" + ][0]["rules"][0]["pattern"] = "changed" + changed = registry.validate_config(changed_values) + registry.create_engine(changed.entity_processing.stages[0].config) + + assert compile_calls == 2 + regex_module._clear_compiled_pattern_cache() + + +@pytest.mark.parametrize( + "catalog_path", + [ + "missing.yaml", + "../patterns.yaml", + "patterns.json", + ], +) +def test_catalog_file_rejects_invalid_paths( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + catalog_path: str, +) -> None: + values = _config() + values["entity_processing"]["stages"][0]["config"]["pattern_catalog"] = catalog_path + monkeypatch.chdir(tmp_path) + + with pytest.raises(PrivacyGuardError) as exception_info: + _registry().validate_config(values) + + assert exception_info.value.code is ErrorCode.CONFIG_INVALID + + +def test_catalog_file_rejects_absolute_paths( + tmp_path: Path, +) -> None: + values = _config() + values["entity_processing"]["stages"][0]["config"]["pattern_catalog"] = str( + tmp_path / "patterns.yaml" + ) + + with pytest.raises(PrivacyGuardError) as exception_info: + _registry().validate_config(values) + + assert exception_info.value.code is ErrorCode.CONFIG_INVALID + + +def test_catalog_file_rejects_symlinks( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + target = tmp_path / "target.yaml" + target.write_text("entities: []\n", encoding="utf-8") + (tmp_path / "patterns.yaml").symlink_to(target) + values = _config() + values["entity_processing"]["stages"][0]["config"]["pattern_catalog"] = ( + "patterns.yaml" + ) + monkeypatch.chdir(tmp_path) + + with pytest.raises(PrivacyGuardError) as exception_info: + _registry().validate_config(values) + + assert exception_info.value.code is ErrorCode.CONFIG_INVALID + + +def test_catalog_file_rejects_a_symlink_swap_during_open( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + catalog_root = tmp_path / "catalog-root" + catalog_root.mkdir() + catalog_path = catalog_root / "patterns.yaml" + values = _config() + catalog = values["entity_processing"]["stages"][0]["config"]["pattern_catalog"] + catalog_path.write_text(yaml.safe_dump(catalog), encoding="utf-8") + outside_path = tmp_path / "outside.yaml" + outside_path.write_text(yaml.safe_dump(catalog), encoding="utf-8") + values["entity_processing"]["stages"][0]["config"]["pattern_catalog"] = ( + "patterns.yaml" + ) + monkeypatch.chdir(catalog_root) + + original_open = os.open + swapped = False + + def swap_before_final_open( + path: str | bytes | Path, + flags: int, + mode: int = 0o777, + *, + dir_fd: int | None = None, + ) -> int: + nonlocal swapped + if path == "patterns.yaml" and dir_fd is not None and not swapped: + swapped = True + catalog_path.unlink() + catalog_path.symlink_to(outside_path) + return original_open(path, flags, mode, dir_fd=dir_fd) + + monkeypatch.setattr(regex_module.os, "open", swap_before_final_open) + + with pytest.raises(PrivacyGuardError) as exception_info: + _registry().validate_config(values) + + assert swapped is True + assert exception_info.value.code is ErrorCode.CONFIG_INVALID + + +def test_catalog_file_rejects_a_fifo_without_blocking(tmp_path: Path) -> None: + os.mkfifo(tmp_path / "patterns.yaml") + probe = """ +from privacy_guard.engines.regex import _load_pattern_catalog_file + +try: + _load_pattern_catalog_file("patterns.yaml") +except ValueError: + pass +else: + raise AssertionError("FIFO catalog was accepted") +""" + + subprocess.run( + [sys.executable, "-c", probe], + cwd=tmp_path, + check=True, + timeout=5, + ) + + +@pytest.mark.parametrize( + "contents", + [ + "entities:\n - name: first\n name: duplicate\n rules: []\n", + ( + "entities:\n" + " - &shared\n" + " name: first\n" + " rules:\n" + " - pattern: x\n" + " confidence: high\n" + " - *shared\n" + ), + "entities: !!python/object/apply:builtins.list []\n", + ], +) +def test_catalog_file_rejects_unsafe_yaml( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + contents: str, +) -> None: + (tmp_path / "patterns.yaml").write_text(contents, encoding="utf-8") + values = _config() + values["entity_processing"]["stages"][0]["config"]["pattern_catalog"] = ( + "patterns.yaml" + ) + monkeypatch.chdir(tmp_path) + + with pytest.raises(PrivacyGuardError) as exception_info: + _registry().validate_config(values) + + assert exception_info.value.code is ErrorCode.CONFIG_INVALID + + +def test_catalog_file_rejects_invalid_utf8( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + (tmp_path / "patterns.yaml").write_bytes(b"\xff") + values = _config() + values["entity_processing"]["stages"][0]["config"]["pattern_catalog"] = ( + "patterns.yaml" + ) + monkeypatch.chdir(tmp_path) + + with pytest.raises(PrivacyGuardError) as exception_info: + _registry().validate_config(values) + + assert exception_info.value.code is ErrorCode.CONFIG_INVALID + + +def test_catalog_file_rejects_oversized_content( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(regex_module, "MAX_REGEX_CATALOG_FILE_BYTES", 1) + (tmp_path / "patterns.yaml").write_text("entities: []\n", encoding="utf-8") + values = _config() + values["entity_processing"]["stages"][0]["config"]["pattern_catalog"] = ( + "patterns.yaml" + ) + monkeypatch.chdir(tmp_path) + + with pytest.raises(PrivacyGuardError) as exception_info: + _registry().validate_config(values) + + assert exception_info.value.code is ErrorCode.CONFIG_INVALID + + +def test_replace_requires_a_replacement_recipe_on_every_stage() -> None: + with pytest.raises(PrivacyGuardError) as exception_info: + _registry().validate_config(_config(action="replace")) + + assert exception_info.value.code is ErrorCode.CONFIG_INVALID + + +@pytest.mark.parametrize("action", ["detect", "block"]) +def test_dormant_replacement_recipe_is_valid_for_detection_only_actions( + action: str, +) -> None: + config = _registry().validate_config( + _config( + action=action, + replacement={"strategy": "template", "template": "[redacted]"}, + ) + ) + engine_config = config.entity_processing.stages[0].config + + assert isinstance(engine_config, RegexEngineConfig) + assert engine_config.replacement is not None + + +@pytest.mark.parametrize( + "mutation", + [ + lambda values: values.update({"body_format": "json"}), + lambda values: values.update({"on_finding": {"action": "observe"}}), + lambda values: values["on_detection"].update({"action": "observe"}), + lambda values: values["on_detection"].update({"action": "redact"}), + lambda values: values["entity_processing"]["stages"][0]["config"].update( + {"kind": "regex"} + ), + lambda values: values["entity_processing"]["stages"][0]["config"].update( + {"preset": "pii"} + ), + ], +) +def test_removed_or_unknown_policy_fields_are_rejected( + mutation: Callable[[dict[str, object]], None], +) -> None: + values = _config() + mutation(values) + + with pytest.raises(PrivacyGuardError): + _registry().validate_config(values) + + +def test_stage_list_is_non_empty_and_explicit_names_are_unique() -> None: + empty = _config() + empty["entity_processing"]["stages"] = [] + duplicate = _config(stage_name="same") + duplicate["entity_processing"]["stages"].append( + deepcopy(duplicate["entity_processing"]["stages"][0]) + ) + + with pytest.raises(PrivacyGuardError): + _registry().validate_config(empty) + with pytest.raises(PrivacyGuardError): + _registry().validate_config(duplicate) + + +def test_explicit_stage_name_cannot_collide_with_a_derived_name() -> None: + values = _config(stage_name="regex[2]") + values["entity_processing"]["stages"].append( + deepcopy(_config()["entity_processing"]["stages"][0]) + ) + + with pytest.raises(PrivacyGuardError): + _registry().validate_config(values) + + +def test_regex_rule_names_are_optional_but_supplied_names_are_unique() -> None: + values = _config() + rules = values["entity_processing"]["stages"][0]["config"]["pattern_catalog"][ + "entities" + ][0]["rules"] + rules.extend( + [ + {"pattern": "second", "confidence": "low"}, + {"name": "named", "pattern": "third", "confidence": "medium"}, + ] + ) + config = _registry().validate_config(values) + + regex_config = config.entity_processing.stages[0].config + assert isinstance(regex_config, RegexEngineConfig) + parsed_rules = regex_config.pattern_catalog.entities[0].rules + assert [rule.name for rule in parsed_rules] == [None, None, "named"] + + rules.append({"name": "named", "pattern": "duplicate", "confidence": "high"}) + with pytest.raises(PrivacyGuardError): + _registry().validate_config(values) + + +def test_validated_config_equality_covers_concrete_expanded_config() -> None: + registry = _registry() + first = registry.validate_config(_config()) + equivalent = registry.validate_config(deepcopy(_config())) + changed_values = _config() + changed_values["entity_processing"]["stages"][0]["config"]["pattern_catalog"][ + "entities" + ][0]["rules"][0]["confidence"] = "low" + changed = registry.validate_config(changed_values) + + assert first == equivalent + assert first != changed + + +def test_models_are_frozen_and_hide_engine_configuration_from_repr() -> None: + config = _registry().validate_config(_config()) + pattern = "sensitive-pattern-value" + + with pytest.raises(ValidationError): + setattr(config.on_detection, "action", PolicyAction.BLOCK) + assert pattern not in repr(config) diff --git a/projects/privacy-guard/tests/test_errors.py b/projects/privacy-guard/tests/test_errors.py new file mode 100644 index 0000000..e38e0ed --- /dev/null +++ b/projects/privacy-guard/tests/test_errors.py @@ -0,0 +1,45 @@ +import inspect + +from privacy_guard.errors import ( + ErrorCode, + ErrorComponent, + ErrorKind, + PrivacyGuardError, +) + + +def test_every_error_code_has_one_safe_complete_specification() -> None: + sentinel = "sensitive-request-value-8472" + + assert len({code.value for code in ErrorCode}) == len(ErrorCode) + for code in ErrorCode: + error = PrivacyGuardError(code) + message = str(error) + + assert f"[{code.value}]" in message + assert error.component.value in message + assert error.operation in message + assert error.summary in message + assert error.hint in message + assert sentinel not in message + assert repr(error) == f"PrivacyGuardError({message!r})" + + +def test_error_kinds_distinguish_invalid_input_from_internal_failures() -> None: + assert PrivacyGuardError(ErrorCode.CONFIG_INVALID).kind is ErrorKind.INVALID_INPUT + assert ( + PrivacyGuardError(ErrorCode.ENGINE_EXECUTION_FAILED).kind is ErrorKind.INTERNAL + ) + assert ( + PrivacyGuardError(ErrorCode.CONFIG_INVALID).component is ErrorComponent.CONFIG + ) + + +def test_config_error_explains_the_transport_size_limit() -> None: + error = PrivacyGuardError(ErrorCode.CONFIG_INVALID) + + assert "encoded configuration at or below 64 KiB" in error.hint + + +def test_privacy_guard_error_exposes_only_a_catalog_code_parameter() -> None: + assert list(inspect.signature(PrivacyGuardError).parameters) == ["code"] diff --git a/projects/privacy-guard/tests/test_gateway_config.py b/projects/privacy-guard/tests/test_gateway_config.py new file mode 100644 index 0000000..1632dfc --- /dev/null +++ b/projects/privacy-guard/tests/test_gateway_config.py @@ -0,0 +1,194 @@ +"""OpenShell gateway configuration update tests.""" + +from __future__ import annotations + +import tomllib +from pathlib import Path + +import pytest + +from privacy_guard.gateway_config import ( + MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES, + GatewayConfigError, + GatewayConfigUpdate, + default_gateway_config_path, + update_gateway_config, + validate_middleware_name, +) + + +def test_default_gateway_config_path_uses_xdg_config_home( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.delenv("OPENSHELL_GATEWAY_CONFIG", raising=False) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + + assert default_gateway_config_path() == tmp_path / "openshell" / "gateway.toml" + + +def test_default_gateway_config_path_uses_home_config_without_xdg( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.delenv("OPENSHELL_GATEWAY_CONFIG", raising=False) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + assert ( + default_gateway_config_path() + == tmp_path / ".config" / "openshell" / "gateway.toml" + ) + + +def test_default_gateway_config_path_honors_openshell_override( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + configured_path = tmp_path / "custom.toml" + monkeypatch.setenv("OPENSHELL_GATEWAY_CONFIG", str(configured_path)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "ignored")) + + assert default_gateway_config_path() == configured_path + + +def test_middleware_name_validation_matches_openshell_constraints() -> None: + longest_name = "a" * MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES + + assert validate_middleware_name(longest_name) == longest_name + + for invalid_name in ( + "", + "a" * (MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES + 1), + "privacy guard", + "priväcy-guard", + "openshell/privacy-guard", + ): + with pytest.raises(GatewayConfigError): + validate_middleware_name(invalid_name) + + +def test_update_gateway_config_creates_minimal_default_config( + tmp_path: Path, +) -> None: + path = tmp_path / "openshell" / "gateway.toml" + + result = update_gateway_config( + path, + middleware_name="privacy-guard", + host_ip="192.168.1.20", + port=50051, + ) + + assert result is GatewayConfigUpdate.CREATED + assert path.stat().st_mode & 0o777 == 0o600 + assert tomllib.loads(path.read_text()) == { + "openshell": { + "version": 1, + "supervisor": { + "middleware": [ + { + "name": "privacy-guard", + "grpc_endpoint": "http://192.168.1.20:50051", + "max_body_bytes": 4_194_304, + "timeout": "5s", + } + ] + }, + } + } + + +def test_update_gateway_config_appends_without_rewriting_existing_settings( + tmp_path: Path, +) -> None: + path = tmp_path / "gateway.toml" + original = ( + "# Keep this operator comment.\n" + "[openshell]\n" + "version = 1\n\n" + "[openshell.gateway]\n" + 'compute_drivers = ["docker"]\n' + ) + path.write_text(original) + + result = update_gateway_config( + path, + middleware_name="privacy-guard", + host_ip="10.0.0.12", + port=50052, + ) + + assert result is GatewayConfigUpdate.ADDED + assert path.read_text().startswith(original.rstrip() + "\n\n") + + +def test_update_gateway_config_updates_only_the_named_registration( + tmp_path: Path, +) -> None: + path = tmp_path / "gateway.toml" + path.write_text( + "[openshell]\n" + "version = 1\n\n" + "[[openshell.supervisor.middleware]]\n" + 'name = "other-service"\n' + 'grpc_endpoint = "http://10.0.0.2:9000"\n' + "max_body_bytes = 1000\n" + 'timeout = "1s"\n\n' + "[[openshell.supervisor.middleware]]\n" + 'name = "privacy-guard"\n' + "# Keep this registration comment.\n" + 'grpc_endpoint = "http://10.0.0.3:50051"\n' + "max_body_bytes = 2048\n" + 'timeout = "2s"\n' + ) + + result = update_gateway_config( + path, + middleware_name="privacy-guard", + host_ip="10.0.0.4", + port=50053, + ) + + assert result is GatewayConfigUpdate.UPDATED + contents = path.read_text() + assert 'grpc_endpoint = "http://10.0.0.2:9000"' in contents + assert "# Keep this registration comment." in contents + assert 'grpc_endpoint = "http://10.0.0.4:50053"' in contents + assert "max_body_bytes = 4194304" in contents + assert 'timeout = "5s"' in contents + + repeated = update_gateway_config( + path, + middleware_name="privacy-guard", + host_ip="10.0.0.4", + port=50053, + ) + + assert repeated is GatewayConfigUpdate.UNCHANGED + + +@pytest.mark.parametrize( + "contents", + [ + "[openshell\n", + "[other]\nversion = 1\n", + "[openshell]\nversion = 2\n", + ], +) +def test_update_gateway_config_rejects_invalid_existing_config( + tmp_path: Path, + contents: str, +) -> None: + path = tmp_path / "gateway.toml" + path.write_text(contents) + + with pytest.raises(GatewayConfigError): + update_gateway_config( + path, + middleware_name="privacy-guard", + host_ip="192.168.1.20", + port=50051, + ) + + assert path.read_text() == contents diff --git a/projects/privacy-guard/tests/test_logging.py b/projects/privacy-guard/tests/test_logging.py new file mode 100644 index 0000000..6521c4d --- /dev/null +++ b/projects/privacy-guard/tests/test_logging.py @@ -0,0 +1,157 @@ +"""Privacy Guard logging configuration tests.""" + +from __future__ import annotations + +import ast +import logging +import re +from collections.abc import Iterator +from io import StringIO +from pathlib import Path + +import pytest + +from privacy_guard.logging import ( + DEFAULT_LOGGING_CONFIG, + ColorMode, + LoggingConfig, + configure_logging, + get_logger, + reset_logging, +) + + +@pytest.fixture(autouse=True) +def _restore_logging() -> Iterator[None]: + reset_logging() + yield + reset_logging() + + +def test_configure_logging_emits_consistent_package_logs() -> None: + stream = StringIO() + configure_logging(LoggingConfig(stream=stream)) + + logging.getLogger("privacy_guard.service").info("server_started") + logging.getLogger("privacy_guard.service").debug("hidden_detail") + + output = stream.getvalue() + assert re.fullmatch( + r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}" + r" \| INFO \| privacy_guard\.service \| server_started\n", + output, + ) + assert "hidden_detail" not in output + assert "\033[" not in output + + +def test_default_logging_config_uses_info_and_terminal_aware_colors() -> None: + assert DEFAULT_LOGGING_CONFIG == LoggingConfig( + level=logging.INFO, + stream=None, + color_mode=ColorMode.AUTO, + ) + + +def test_configure_logging_colors_interactive_output() -> None: + stream = _TerminalStream() + configure_logging(LoggingConfig(stream=stream)) + + logging.getLogger("privacy_guard.service").warning("resource_pressure") + + output = stream.getvalue() + assert "\033[33mWARNING \033[0m" in output + assert "\033[36mprivacy_guard.service\033[0m" in output + assert output.endswith(" | resource_pressure\n") + + +def test_configure_logging_can_disable_terminal_colors() -> None: + stream = _TerminalStream() + configure_logging(LoggingConfig(stream=stream, color_mode=ColorMode.NEVER)) + + logging.getLogger("privacy_guard.service").error("startup_failed") + + assert "\033[" not in stream.getvalue() + + +def test_configure_logging_can_force_colors_for_redirected_output() -> None: + stream = StringIO() + configure_logging(LoggingConfig(stream=stream, color_mode=ColorMode.ALWAYS)) + + logging.getLogger("privacy_guard.service").info("server_started") + + assert "\033[32mINFO \033[0m" in stream.getvalue() + + +def test_get_logger_returns_named_package_logger() -> None: + logger = get_logger("privacy_guard.custom_engine") + + assert logger is logging.getLogger("privacy_guard.custom_engine") + + +def test_configure_logging_accepts_native_log_levels() -> None: + stream = StringIO() + configure_logging(LoggingConfig(level=logging.DEBUG, stream=stream)) + + logging.getLogger("privacy_guard.request_processor").debug("processing_diagnostic") + + assert ( + "DEBUG | privacy_guard.request_processor | processing_diagnostic" + in stream.getvalue() + ) + + +def test_configure_logging_replaces_its_previous_handler() -> None: + first_stream = StringIO() + second_stream = StringIO() + configure_logging(LoggingConfig(stream=first_stream)) + configure_logging(LoggingConfig(stream=second_stream)) + + logging.getLogger("privacy_guard").info("configured_once") + + assert "configured_once" not in first_stream.getvalue() + assert second_stream.getvalue().count("configured_once") == 1 + + +def test_reset_logging_restores_application_logging() -> None: + package_logger = logging.getLogger("privacy_guard") + application_handler = logging.NullHandler() + package_logger.addHandler(application_handler) + configure_logging(LoggingConfig(stream=StringIO())) + + reset_logging() + + try: + assert package_logger.handlers == [application_handler] + assert package_logger.level == logging.NOTSET + assert package_logger.propagate is True + finally: + package_logger.removeHandler(application_handler) + + +def test_source_modules_use_the_shared_logging_module() -> None: + direct_imports: list[str] = [] + for path in _SOURCE_ROOT.rglob("*.py"): + if path == _LOGGING_MODULE or "bindings" in path.parts: + continue + tree = ast.parse(path.read_text(encoding="utf-8")) + if any( + ( + isinstance(node, ast.Import) + and any(imported.name == "logging" for imported in node.names) + ) + or (isinstance(node, ast.ImportFrom) and node.module == "logging") + for node in ast.walk(tree) + ): + direct_imports.append(str(path.relative_to(_SOURCE_ROOT))) + + assert direct_imports == [] + + +_SOURCE_ROOT = Path(__file__).parents[1] / "src" / "privacy_guard" +_LOGGING_MODULE = _SOURCE_ROOT / "logging.py" + + +class _TerminalStream(StringIO): + def isatty(self) -> bool: + return True diff --git a/projects/privacy-guard/tests/test_request_processor.py b/projects/privacy-guard/tests/test_request_processor.py new file mode 100644 index 0000000..be5a22c --- /dev/null +++ b/projects/privacy-guard/tests/test_request_processor.py @@ -0,0 +1,221 @@ +"""RequestProcessor tests for the one-text, ordered-stage contract.""" + +from __future__ import annotations + +import logging +from concurrent.futures import ThreadPoolExecutor +from time import monotonic + +import pytest + +from privacy_guard.config import PolicyAction +from privacy_guard.constants import MAX_BODY_BYTES +from privacy_guard.engines import RegexEngine +from privacy_guard.engines.registry import EngineRegistry +from privacy_guard.errors import ( + EngineConfigurationError, + EngineLimitExceededError, + ErrorCode, + PrivacyGuardError, +) +from privacy_guard.request_processor import RequestDecision, RequestProcessor +from privacy_guard.string_validators import validate_scalar_string +from privacy_guard.timeout import Timeout + + +def _values( + action: PolicyAction, + *, + include_second_stage: bool = True, +) -> dict[str, object]: + stages: list[dict[str, object]] = [ + { + "name": "people", + "config": { + "engine": "regex", + "pattern_catalog": { + "entities": [ + { + "name": "person", + "rules": [ + { + "pattern": "Alice", + "confidence": "high", + } + ], + } + ] + }, + "replacement": { + "strategy": "template", + "template": "[{entity}]", + }, + }, + } + ] + if include_second_stage: + stages.append( + { + "config": { + "engine": "regex", + "pattern_catalog": { + "entities": [ + { + "name": "marker", + "rules": [ + { + "pattern": "person", + "confidence": "medium", + } + ], + } + ] + }, + "replacement": { + "strategy": "template", + "template": "<{entity}>", + }, + }, + } + ) + return { + "entity_processing": {"stages": stages}, + "on_detection": {"action": action.value}, + } + + +def _processor( + action: PolicyAction, + *, + include_second_stage: bool = True, +) -> RequestProcessor: + registry = EngineRegistry() + registry.register(RegexEngine) + registry.finalize() + config = registry.validate_config( + _values(action, include_second_stage=include_second_stage) + ) + stages = tuple( + ( + stage.diagnostic_name(index), + registry.create_engine(stage.config), + ) + for index, stage in enumerate(config.entity_processing.stages, start=1) + ) + return RequestProcessor(config, stages) + + +def test_replace_runs_stages_sequentially_over_the_current_text() -> None: + result = _processor(PolicyAction.REPLACE).process("Hello Alice") + + assert result.decision is RequestDecision.ALLOW + assert result.replacement_text == "Hello []" + assert tuple( + (item.entity, item.source_stage, item.count) + for item in result.detection_summaries + ) == ( + ("person", "people", 1), + ("marker", "regex[2]", 1), + ) + + +def test_detect_reports_without_returning_replacement_text() -> None: + result = _processor(PolicyAction.DETECT).process("Hello Alice") + + assert result.decision is RequestDecision.ALLOW + assert result.replacement_text is None + assert tuple(item.entity for item in result.detection_summaries) == ("person",) + + +def test_block_is_a_processor_disposition_not_an_engine_strategy() -> None: + result = _processor(PolicyAction.BLOCK).process("Hello Alice") + + assert result.decision is RequestDecision.DENY + assert result.replacement_text is None + assert result.reason_code == "privacy_guard_blocked" + assert tuple(item.entity for item in result.detection_summaries) == ("person",) + + +def test_scalar_validation_rejects_lone_surrogates() -> None: + with pytest.raises(ValueError, match="Unicode scalar"): + validate_scalar_string("\ud800") + + +def test_processor_accepts_exact_body_limit_and_rejects_one_byte_more() -> None: + processor = _processor(PolicyAction.DETECT, include_second_stage=False) + + exact = processor.process("x" * MAX_BODY_BYTES) + + assert exact.decision is RequestDecision.ALLOW + with pytest.raises(PrivacyGuardError) as captured: + processor.process("x" * (MAX_BODY_BYTES + 1)) + assert captured.value.code is ErrorCode.REQUEST_BODY_TOO_LARGE + + +def test_exact_limit_requests_complete_concurrently() -> None: + processor = _processor(PolicyAction.DETECT, include_second_stage=False) + text = "x" * MAX_BODY_BYTES + + with ThreadPoolExecutor(max_workers=4) as executor: + results = tuple(executor.map(processor.process, (text,) * 4)) + + assert all(result.decision is RequestDecision.ALLOW for result in results) + + +def test_exact_limit_request_completes_with_multiple_stages() -> None: + result = _processor(PolicyAction.DETECT).process("x" * MAX_BODY_BYTES) + + assert result.decision is RequestDecision.ALLOW + + +def test_timeout_returns_the_bounded_limit_deny( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + monkeypatch.setattr( + Timeout, + "from_seconds", + classmethod(lambda cls, seconds: cls(deadline=monotonic() - 1)), + ) + + with caplog.at_level(logging.INFO, logger="privacy_guard.request_processor"): + result = _processor(PolicyAction.DETECT).process("Hello Alice") + + assert result.decision is RequestDecision.DENY + assert result.reason_code == "privacy_guard_limit_exceeded" + assert "privacy_guard_processing_limit kind=timeout" in caplog.text + assert "Alice" not in caplog.text + + +def test_engine_resource_limit_returns_the_bounded_limit_deny( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + def exceed_limit(*_: object, **__: object) -> object: + raise EngineLimitExceededError("sensitive resource detail") + + monkeypatch.setattr(RegexEngine, "_run", exceed_limit) + + with caplog.at_level(logging.INFO, logger="privacy_guard.request_processor"): + result = _processor(PolicyAction.DETECT).process("Hello Alice") + + assert result.decision is RequestDecision.DENY + assert result.reason_code == "privacy_guard_limit_exceeded" + assert "privacy_guard_processing_limit kind=resource" in caplog.text + assert "Alice" not in caplog.text + assert "sensitive resource detail" not in caplog.text + + +def test_engine_configuration_failure_maps_to_invalid_config( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def reject_config(*_: object, **__: object) -> object: + raise EngineConfigurationError("sensitive configuration detail") + + monkeypatch.setattr(RegexEngine, "_run", reject_config) + + with pytest.raises(PrivacyGuardError) as captured: + _processor(PolicyAction.DETECT).process("Hello Alice") + + assert captured.value.code is ErrorCode.CONFIG_INVALID + assert "sensitive configuration detail" not in str(captured.value) diff --git a/projects/privacy-guard/tests/test_timeout.py b/projects/privacy-guard/tests/test_timeout.py new file mode 100644 index 0000000..778e97b --- /dev/null +++ b/projects/privacy-guard/tests/test_timeout.py @@ -0,0 +1,46 @@ +"""Tests for the shared entity-processing timeout.""" + +from time import monotonic + +import pytest + +from privacy_guard.errors import TimeoutExpiredError +from privacy_guard.timeout import Timeout + + +@pytest.mark.parametrize("seconds", [True, 0, -1, float("inf"), 31]) +def test_timeout_duration_is_strict_positive_and_bounded( + seconds: bool | int | float, +) -> None: + with pytest.raises( + ValueError, + match="finite number greater than 0 and at most 30", + ): + Timeout.from_seconds(seconds) + + +def test_expired_timeout_raises_typed_signal() -> None: + timeout = Timeout(deadline=monotonic() - 1) + + with pytest.raises(TimeoutExpiredError) as captured: + timeout.raise_if_expired() + + assert str(captured.value) == ( + "Privacy Guard processing timed out. Reduce the request size or simplify " + "the configured stages and rules, or increase the processing timeout " + "to at most 30 seconds, then retry." + ) + + +def test_timeout_context_translates_delegated_timeout_error() -> None: + timeout = Timeout.from_seconds(1) + + with pytest.raises(TimeoutExpiredError), timeout.enforce(): + raise TimeoutError + + +def test_timeout_context_preserves_unrelated_errors() -> None: + timeout = Timeout.from_seconds(1) + + with pytest.raises(RuntimeError), timeout.enforce(): + raise RuntimeError diff --git a/projects/privacy-guard/tests/test_typing_policy.py b/projects/privacy-guard/tests/test_typing_policy.py new file mode 100644 index 0000000..b46991d --- /dev/null +++ b/projects/privacy-guard/tests/test_typing_policy.py @@ -0,0 +1,583 @@ +"""AST-enforced type-safety policy for handwritten Python.""" + +from __future__ import annotations + +import ast +from enum import Enum, auto +from pathlib import Path + +from typing_extensions import override + +_HANDWRITTEN_ROOTS = ("src", "tests", "examples") +_GENERATED_BINDINGS = Path("src/privacy_guard/bindings") +_TYPING_MODULES = frozenset({"typing", "typing_extensions"}) + + +class _TypingOrigin(Enum): + MODULE = auto() + CAST = auto() + DYNAMIC = auto() + LITERAL = auto() + ANNOTATED = auto() + + +class _TypingPolicyVisitor(ast.NodeVisitor): + """Resolve prohibited typing symbols without matching unrelated names.""" + + def __init__(self, relative_path: Path) -> None: + self._relative_path = relative_path + self._scopes: list[dict[str, _TypingOrigin | None]] = [{}] + self.violations: list[str] = [] + + def _record(self, node: ast.AST, message: str) -> None: + line_number = ( + node.lineno if isinstance(node, ast.expr | ast.stmt | ast.arg) else 0 + ) + self.violations.append( + f"{self._relative_path}:{line_number}: prohibited {message}" + ) + + def _lookup(self, name: str) -> _TypingOrigin | None: + for scope in reversed(self._scopes): + if name in scope: + return scope[name] + return None + + def _expression_origin(self, node: ast.expr) -> _TypingOrigin | None: + if isinstance(node, ast.Name): + return self._lookup(node.id) + if isinstance(node, ast.Attribute): + owner_origin = self._expression_origin(node.value) + if owner_origin is _TypingOrigin.MODULE: + if node.attr == "cast": + return _TypingOrigin.CAST + if node.attr == "Any": + return _TypingOrigin.DYNAMIC + if node.attr == "Literal": + return _TypingOrigin.LITERAL + if node.attr == "Annotated": + return _TypingOrigin.ANNOTATED + return None + + def _bind(self, name: str, origin: _TypingOrigin | None) -> None: + self._scopes[-1][name] = origin + + def _bind_target( + self, target: ast.expr, origin: _TypingOrigin | None = None + ) -> None: + if isinstance(target, ast.Name): + self._bind(target.id, origin) + elif isinstance(target, ast.List | ast.Tuple): + for element in target.elts: + self._bind_target(element) + elif isinstance(target, ast.Starred): + self._bind_target(target.value) + + def _visit_annotation(self, annotation: ast.expr | None) -> None: + if annotation is None: + return + self._visit_type_expression(annotation) + + @staticmethod + def _subscript_items(node: ast.expr) -> tuple[ast.expr, ...]: + return tuple(node.elts) if isinstance(node, ast.Tuple) else (node,) + + def _visit_type_expression(self, node: ast.expr) -> None: + if self._expression_origin(node) is _TypingOrigin.DYNAMIC: + self._record(node, "explicit Any") + return + if isinstance(node, ast.Constant) and isinstance(node.value, str): + try: + parsed = ast.parse(node.value, mode="eval") + except SyntaxError: + return + if self._type_expression_contains_dynamic(parsed.body): + self._record(node, "explicit Any annotation") + return + if isinstance(node, ast.Subscript): + self.visit(node.value) + items = self._subscript_items(node.slice) + origin = self._expression_origin(node.value) + if origin is _TypingOrigin.LITERAL: + for item in items: + self.visit(item) + return + if origin is _TypingOrigin.ANNOTATED and items: + self._visit_type_expression(items[0]) + for item in items[1:]: + self.visit(item) + return + for item in items: + self._visit_type_expression(item) + return + if isinstance(node, ast.BinOp): + self._visit_type_expression(node.left) + self._visit_type_expression(node.right) + return + if isinstance(node, ast.List | ast.Tuple): + for item in node.elts: + self._visit_type_expression(item) + return + if isinstance(node, ast.Starred): + self._visit_type_expression(node.value) + return + self.visit(node) + + def _inspect_type_comment( + self, + node: ast.stmt | ast.arg, + type_comment: str | None, + *, + function: bool = False, + ) -> None: + if type_comment is None: + return + try: + parsed = ast.parse(type_comment, mode="func_type" if function else "eval") + except SyntaxError: + return + if self._type_expression_contains_dynamic(parsed): + self._record(node, "explicit Any type comment") + + def _value_contains_dynamic(self, node: ast.AST) -> bool: + if ( + isinstance(node, ast.expr) + and self._expression_origin(node) is _TypingOrigin.DYNAMIC + ): + return True + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return False + return any( + self._value_contains_dynamic(child) for child in ast.iter_child_nodes(node) + ) + + def _type_expression_contains_dynamic(self, node: ast.AST, depth: int = 0) -> bool: + if depth > 8: + return False + if ( + isinstance(node, ast.expr) + and self._expression_origin(node) is _TypingOrigin.DYNAMIC + ): + return True + if isinstance(node, ast.Constant) and isinstance(node.value, str): + try: + parsed = ast.parse(node.value, mode="eval") + except SyntaxError: + return False + return self._type_expression_contains_dynamic(parsed.body, depth + 1) + if isinstance(node, ast.Subscript): + items = self._subscript_items(node.slice) + origin = self._expression_origin(node.value) + if origin is _TypingOrigin.LITERAL: + return any(self._value_contains_dynamic(item) for item in items) + if origin is _TypingOrigin.ANNOTATED and items: + return self._type_expression_contains_dynamic(items[0], depth) or any( + self._value_contains_dynamic(item) for item in items[1:] + ) + return any( + self._type_expression_contains_dynamic(child, depth) + for child in ast.iter_child_nodes(node) + ) + + def _visit_arguments(self, arguments: ast.arguments) -> None: + all_arguments = ( + *arguments.posonlyargs, + *arguments.args, + *arguments.kwonlyargs, + ) + for argument in all_arguments: + self._visit_annotation(argument.annotation) + self._inspect_type_comment(argument, argument.type_comment) + if arguments.vararg is not None: + self._visit_annotation(arguments.vararg.annotation) + self._inspect_type_comment(arguments.vararg, arguments.vararg.type_comment) + if arguments.kwarg is not None: + self._visit_annotation(arguments.kwarg.annotation) + self._inspect_type_comment(arguments.kwarg, arguments.kwarg.type_comment) + for default in arguments.defaults: + self.visit(default) + for default in arguments.kw_defaults: + if default is not None: + self.visit(default) + + def _bind_arguments(self, arguments: ast.arguments) -> None: + for argument in ( + *arguments.posonlyargs, + *arguments.args, + *arguments.kwonlyargs, + ): + self._bind(argument.arg, None) + if arguments.vararg is not None: + self._bind(arguments.vararg.arg, None) + if arguments.kwarg is not None: + self._bind(arguments.kwarg.arg, None) + + def _visit_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: + for decorator in node.decorator_list: + self.visit(decorator) + self._visit_arguments(node.args) + self._visit_annotation(node.returns) + self._inspect_type_comment(node, node.type_comment, function=True) + self._bind(node.name, None) + self._scopes.append({}) + try: + self._bind_arguments(node.args) + for statement in node.body: + self.visit(statement) + finally: + self._scopes.pop() + + @override + def visit_Import(self, node: ast.Import) -> None: + for imported in node.names: + bound_name = imported.asname or imported.name.split(".", maxsplit=1)[0] + origin = _TypingOrigin.MODULE if imported.name in _TYPING_MODULES else None + self._bind(bound_name, origin) + + @override + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + is_typing_import = node.level == 0 and node.module in _TYPING_MODULES + for imported in node.names: + if imported.name == "*": + if is_typing_import: + self._record(node, "typing wildcard import") + continue + bound_name = imported.asname or imported.name + origin: _TypingOrigin | None = None + if is_typing_import and imported.name == "cast": + origin = _TypingOrigin.CAST + self._record(node, "typing cast import") + elif is_typing_import and imported.name == "Any": + origin = _TypingOrigin.DYNAMIC + self._record(node, "explicit Any import") + elif is_typing_import and imported.name == "Literal": + origin = _TypingOrigin.LITERAL + elif is_typing_import and imported.name == "Annotated": + origin = _TypingOrigin.ANNOTATED + self._bind(bound_name, origin) + + @override + def visit_Assign(self, node: ast.Assign) -> None: + self._inspect_type_comment(node, node.type_comment) + self.visit(node.value) + origin = self._expression_origin(node.value) + for target in node.targets: + self._bind_target(target, origin) + + @override + def visit_AnnAssign(self, node: ast.AnnAssign) -> None: + self._visit_annotation(node.annotation) + if node.value is not None: + self.visit(node.value) + origin = self._expression_origin(node.value) + else: + origin = None + self._bind_target(node.target, origin) + + @override + def visit_NamedExpr(self, node: ast.NamedExpr) -> None: + self.visit(node.value) + self._bind_target(node.target, self._expression_origin(node.value)) + + def _visit_for(self, node: ast.For | ast.AsyncFor) -> None: + self.visit(node.iter) + self._bind_target(node.target) + self._inspect_type_comment(node, node.type_comment) + for statement in (*node.body, *node.orelse): + self.visit(statement) + + @override + def visit_For(self, node: ast.For) -> None: + self._visit_for(node) + + @override + def visit_AsyncFor(self, node: ast.AsyncFor) -> None: + self._visit_for(node) + + def _visit_with(self, node: ast.With | ast.AsyncWith) -> None: + for item in node.items: + self.visit(item.context_expr) + if item.optional_vars is not None: + self._bind_target(item.optional_vars) + self._inspect_type_comment(node, node.type_comment) + for statement in node.body: + self.visit(statement) + + @override + def visit_With(self, node: ast.With) -> None: + self._visit_with(node) + + @override + def visit_AsyncWith(self, node: ast.AsyncWith) -> None: + self._visit_with(node) + + @override + def visit_Call(self, node: ast.Call) -> None: + if self._expression_origin(node.func) is _TypingOrigin.CAST: + self._record(node, "typing cast call") + else: + self.visit(node.func) + for argument in node.args: + self.visit(argument) + for keyword in node.keywords: + self.visit(keyword.value) + + @override + def visit_Name(self, node: ast.Name) -> None: + origin = self._lookup(node.id) + if origin is _TypingOrigin.CAST: + self._record(node, "typing cast reference") + elif origin is _TypingOrigin.DYNAMIC: + self._record(node, "explicit Any") + + @override + def visit_Attribute(self, node: ast.Attribute) -> None: + origin = self._expression_origin(node) + if origin is _TypingOrigin.CAST: + self._record(node, "typing cast reference") + elif origin is _TypingOrigin.DYNAMIC: + self._record(node, "explicit Any") + else: + self.visit(node.value) + + @override + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + self._visit_function(node) + + @override + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + self._visit_function(node) + + @override + def visit_Lambda(self, node: ast.Lambda) -> None: + self._visit_arguments(node.args) + self._scopes.append({}) + try: + self._bind_arguments(node.args) + self.visit(node.body) + finally: + self._scopes.pop() + + @override + def visit_ClassDef(self, node: ast.ClassDef) -> None: + for decorator in node.decorator_list: + self.visit(decorator) + for base in node.bases: + self.visit(base) + for keyword in node.keywords: + self.visit(keyword.value) + self._bind(node.name, None) + self._scopes.append({}) + try: + for statement in node.body: + self.visit(statement) + finally: + self._scopes.pop() + + +def _handwritten_python_files(project_root: Path) -> tuple[Path, ...]: + files: list[Path] = [] + for source_root_name in _HANDWRITTEN_ROOTS: + source_root = project_root / source_root_name + if not source_root.is_dir(): + continue + for path in source_root.rglob("*"): + if path.suffix not in {".py", ".pyi"}: + continue + relative_path = path.relative_to(project_root) + if relative_path.is_relative_to(_GENERATED_BINDINGS): + continue + files.append(path) + return tuple(sorted(files)) + + +def _typing_policy_violations(project_root: Path) -> tuple[str, ...]: + violations: list[str] = [] + for path in _handwritten_python_files(project_root): + tree = ast.parse( + path.read_text(encoding="utf-8"), filename=str(path), type_comments=True + ) + visitor = _TypingPolicyVisitor(path.relative_to(project_root)) + visitor.visit(tree) + violations.extend(visitor.violations) + return tuple(violations) + + +def test_handwritten_python_is_cast_free_and_has_no_explicit_any() -> None: + project_root = Path(__file__).resolve().parents[1] + + assert _typing_policy_violations(project_root) == () + + +def test_typing_policy_rejects_all_supported_typing_origins(tmp_path: Path) -> None: + source_root = tmp_path / "src" + source_root.mkdir() + (source_root / "bad.py").write_text( + """ +import typing +import typing as t +import typing_extensions as extensions +from typing import Annotated as TypeAnnotated +from typing import Any as DynamicType +from typing_extensions import Annotated as ExtensionAnnotated +from typing_extensions import cast as narrow + +direct_dynamic: DynamicType +qualified_dynamic: t.Any +quoted_dynamic: "typing.Any" +extension_quoted: "extensions.Any" +nested_quoted: list["typing.Any"] +annotated_nested: t.Annotated["typing.Any", "metadata"] +annotated_alias_nested: TypeAnnotated["typing.Any", "metadata"] +annotated_extension_nested: extensions.Annotated["extensions.Any", "metadata"] +quoted_annotated_nested: "typing.Annotated['typing.Any', 'metadata']" +quoted_annotated_alias_nested: "TypeAnnotated['typing.Any', 'metadata']" +quoted_extension_annotated_alias_nested: ( + "ExtensionAnnotated['extensions.Any', 'metadata']" +) +literal_actual_dynamic: typing.Literal[typing.Any] +annotated_metadata_actual_dynamic: typing.Annotated[str, typing.Any] +direct_cast = narrow(str, object()) +qualified_cast = extensions.cast(str, object()) +rebound = t.cast +rebound_cast = rebound(str, object()) +""", + encoding="utf-8", + ) + (source_root / "bad_argument_comments.py").write_text( + """ +import typing + +def parameters( + positional_only, # type: typing.Any + /, + positional, # type: typing.Any + *variadic, # type: typing.Any + keyword_only, # type: typing.Any + **keywords, # type: typing.Any +): + return positional_only + +async def async_parameter( + value, # type: typing.Any +): + return value +""", + encoding="utf-8", + ) + (source_root / "bad_type_comments.py").write_text( + """ +import typing + +assigned = None # type: typing.Any + +for item in (): # type: typing.Any + pass + +with open(__file__) as stream: # type: typing.Any + pass + +def commented(value): + # type: (typing.Any) -> typing.Any + return value +""", + encoding="utf-8", + ) + + violations = _typing_policy_violations(tmp_path) + regular_violations = tuple( + violation for violation in violations if "src/bad.py:" in violation + ) + type_comment_violations = tuple( + violation + for violation in violations + if "src/bad_type_comments.py:" in violation + ) + argument_comment_violations = tuple( + violation + for violation in violations + if "src/bad_argument_comments.py:" in violation + ) + + assert any("explicit Any import" in violation for violation in regular_violations) + assert sum("explicit Any" in violation for violation in regular_violations) == 14 + assert any("typing cast import" in violation for violation in regular_violations) + assert sum("typing cast call" in violation for violation in regular_violations) == 3 + assert any("typing cast reference" in violation for violation in regular_violations) + assert len(type_comment_violations) == 4 + assert all( + "explicit Any type comment" in violation + for violation in type_comment_violations + ) + assert len(argument_comment_violations) == 6 + assert all( + "explicit Any type comment" in violation + for violation in argument_comment_violations + ) + + +def test_typing_policy_allows_unrelated_cast_and_any_names(tmp_path: Path) -> None: + source_root = tmp_path / "src" + source_root.mkdir() + (source_root / "allowed.py").write_text( + """ +import typing +import typing_extensions as extensions +from typing import Annotated as TypeAnnotated +from typing import Literal as TypeLiteral +from typing_extensions import Annotated as ExtensionAnnotated +from typing_extensions import Literal as ExtensionLiteral + + +class Domain: + Any = "ordinary domain value" + + +class Converter: + def cast(self, value: object) -> object: + return value + + +def cast(value: object) -> object: + return value + + +method_result = Converter().cast(Domain.Any) +function_result = cast(object()) +ordinary_string = "typing.Any" +ordinary_comment = None # type: object +literal_module: typing.Literal["typing.Any"] +literal_alias: TypeLiteral["typing.Any"] +literal_extension: extensions.Literal["extensions.Any"] +literal_extension_alias: ExtensionLiteral["extensions.Any"] +annotated_module: typing.Annotated[str, "typing.Any"] +annotated_alias: TypeAnnotated[str, "typing.Any"] +annotated_extension: extensions.Annotated[str, "extensions.Any"] +annotated_extension_alias: ExtensionAnnotated[str, "extensions.Any"] +quoted_literal: "typing.Literal['typing.Any']" +quoted_annotated: "extensions.Annotated[str, 'extensions.Any']" +quoted_literal_alias: "TypeLiteral['typing.Any']" +quoted_extension_literal_alias: "ExtensionLiteral['extensions.Any']" +quoted_annotated_alias: "TypeAnnotated[str, 'typing.Any']" +quoted_extension_annotated_alias: "ExtensionAnnotated[str, 'extensions.Any']" +""", + encoding="utf-8", + ) + + assert _typing_policy_violations(tmp_path) == () + + +def test_typing_policy_excludes_only_generated_bindings(tmp_path: Path) -> None: + generated = tmp_path / _GENERATED_BINDINGS + generated.mkdir(parents=True) + (generated / "generated.py").write_text( + "from typing import Any, cast\nvalue: Any = cast(Any, None)\n", + encoding="utf-8", + ) + handwritten = tmp_path / "src/privacy_guard/generated_elsewhere.py" + handwritten.write_text("from typing import Any\nvalue: Any\n", encoding="utf-8") + + violations = _typing_policy_violations(tmp_path) + + assert violations + assert all("generated_elsewhere.py" in violation for violation in violations) diff --git a/projects/privacy-guard/uv.lock b/projects/privacy-guard/uv.lock new file mode 100644 index 0000000..9899c41 --- /dev/null +++ b/projects/privacy-guard/uv.lock @@ -0,0 +1,1028 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "boolean-py" +version = "5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047, upload-time = "2025-04-03T10:39:49.734Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl", hash = "sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9", size = 26577, upload-time = "2025-04-03T10:39:48.449Z" }, +] + +[[package]] +name = "cachecontrol" +version = "0.14.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msgpack" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/f6/c972b32d80760fb79d6b9eeb0b3010a46b89c0b23cf6329417ff7886cd22/cachecontrol-0.14.4.tar.gz", hash = "sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1", size = 16150, upload-time = "2025-11-14T04:32:13.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/79/c45f2d53efe6ada1110cf6f9fca095e4ff47a0454444aefdde6ac4789179/cachecontrol-0.14.4-py3-none-any.whl", hash = "sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b", size = 22247, upload-time = "2025-11-14T04:32:11.733Z" }, +] + +[package.optional-dependencies] +filecache = [ + { name = "filelock" }, +] + +[[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 = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, + { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[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 = "cyclonedx-python-lib" +version = "11.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "license-expression" }, + { name = "packageurl-python" }, + { name = "py-serializable" }, + { name = "sortedcontainers" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/c9/5d0ccdd19bc7d8ab803b90695c1706aa2ea8529685d18e682dc2524d2630/cyclonedx_python_lib-11.11.0.tar.gz", hash = "sha256:4b3194db72b613717f2912447e67ab618c75ff7dcac6c4af3c0e9e1ac617c102", size = 1442983, upload-time = "2026-06-17T11:57:49.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/f3/56ccb2884aaa3db5622368e5191a3384b15f35392aa93df8b2f508c660d2/cyclonedx_python_lib-11.11.0-py3-none-any.whl", hash = "sha256:3049fc83e06a059b5c5907a527625a8ed5073caab10607ed4c9e5503b590fd44", size = 528689, upload-time = "2026-06-17T11:57:47.358Z" }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + +[[package]] +name = "filelock" +version = "3.32.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/80/8232b582c4b318b817cf1274ba74976b07b34d35ef439b3eb948f98645a1/filelock-3.32.0.tar.gz", hash = "sha256:7be2ad23a14607ccc71808e68fe30848aeace7058ace17852f68e2a68e310402", size = 213757, upload-time = "2026-07-21T13:17:42.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/79/b4c714bef36bc4ec2beeae1e0c124f0223888cd8c6feb1cdc56038116920/filelock-3.32.0-py3-none-any.whl", hash = "sha256:d396bea984af47333ef05e50eae7eff88c84256de6112aea0ec48a233c064fe3", size = 97732, upload-time = "2026-07-21T13:17:41.55Z" }, +] + +[[package]] +name = "grpcio" +version = "1.82.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/bc/656b89387d6f4ed7e0686c7b64c2ae7e554a759aa58122c8e5fb99392c32/grpcio-1.82.1.tar.gz", hash = "sha256:707b24abd90fcb1e45bcc080577da1dbf9971d107490589b9539af8e1e77b4b5", size = 13187300, upload-time = "2026-07-08T12:36:16.588Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/5b/e5092af97fa671ca279b3e373251af4bf87d5fbda7dc85f6a616899562a7/grpcio-1.82.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:0ddb18a9a9e1f46692b3567ae4abb3f8d117ce6afea48650f8eca06d8ab5d06f", size = 6181472, upload-time = "2026-07-08T12:34:31.009Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/18053a3a2ca03d0c2a1b8cc7271e705007a16aa5dae84bac00935c5b1a7f/grpcio-1.82.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:cf855b1af246720f567b0ce5d0724d45dfa4188eecc3296a2a69257b11b9e94b", size = 11970995, upload-time = "2026-07-08T12:34:33.603Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/21b1acb052876ad00959ec4d1b05fe08607d650bcfa282073bb164c2703c/grpcio-1.82.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb30cb13e25bc13cea70ffc69d6d90c49d36ea6c1d4549e6912f70177834cac", size = 6760127, upload-time = "2026-07-08T12:34:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/3e/12/25eef9c245c54f0061317d13a302357fe8ea03bac240b2b02ececcf54da4/grpcio-1.82.1-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1e822b2774f719c017cbe700b6e47173b6ae290fb84906f52a5a3c2c60b62e1e", size = 7484377, upload-time = "2026-07-08T12:34:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/a0/41/1a348767eb9d9bd7765dc4fa8a01723d3bb386d67f981ee5c6f9c02b8b1c/grpcio-1.82.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5dafb1ece8ed45dee7c738f166ec82e19673221ed5ab8967f72858a4685345b2", size = 6924269, upload-time = "2026-07-08T12:34:40.583Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b9/3aae7a03d34c86ea27988db859a6087c186f6c3f53f9b551e07afd989bfa/grpcio-1.82.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e06503106e7271e0a49fd5a1ac04747f1e47e87d900476db6fe45bc87ee411f4", size = 7531848, upload-time = "2026-07-08T12:34:43.277Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/3c4afa625d0dac9090707966916284c035fc5b2fb3e2c51e156accee6735/grpcio-1.82.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ff99bc8cafb6a952201c37b995f425e641c93ffa6e072258525feab57290141d", size = 8568217, upload-time = "2026-07-08T12:34:45.502Z" }, + { url = "https://files.pythonhosted.org/packages/20/9c/d8489c628e73e20a3d034e7f66912de7b1acb405f01d388f056a88e47924/grpcio-1.82.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:644ae1b94266ac785330f4590a69e52b6a7eb73029043a02209db81c81397d69", size = 7938771, upload-time = "2026-07-08T12:34:48.323Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b7/0a92cfd1658f3a896d4aa12d4efeb7dd4ddfc723725ae22741a5241ea710/grpcio-1.82.1-cp311-cp311-win32.whl", hash = "sha256:e203d2e19d471630084a16c815616f8211dff21c268ab3c5f5bf38417832e074", size = 4256432, upload-time = "2026-07-08T12:34:50.432Z" }, + { url = "https://files.pythonhosted.org/packages/c7/6a/2872c761b025d9ec74386f22a4a7d59c5a5b00ebf718761b33739ffc45de/grpcio-1.82.1-cp311-cp311-win_amd64.whl", hash = "sha256:0d8299c285fe6cc6a1f56badf8d3bc5078c8d20273ee64bafa3783b4bc29a769", size = 5009633, upload-time = "2026-07-08T12:34:52.67Z" }, + { url = "https://files.pythonhosted.org/packages/dc/88/d1350bf3343a2ed87d801584e40609f6c6bd3087926eeca03de50348cf4a/grpcio-1.82.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:c09bd5fa0d5b1fbd773ec349fe61441c3e4ebf168c229aa7538a820bdfad6a58", size = 6144689, upload-time = "2026-07-08T12:34:55.567Z" }, + { url = "https://files.pythonhosted.org/packages/e6/33/71875cdecd27c24ac1385d4783a09853f01b84a825a36aec2a2bc7d0d080/grpcio-1.82.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1eae24810720734598e3e6a1a528d5de0f265fe3fc86575e9ecce424b9ec7379", size = 11952034, upload-time = "2026-07-08T12:34:58.128Z" }, + { url = "https://files.pythonhosted.org/packages/82/b2/d9125df3d8a140dec12cc82c05b7deafedeababcff6496f28b2fd5634d10/grpcio-1.82.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a6bd5daf5bde7b24d7ad2cbaf8bf9eac620d96222016bb5e7ddde930dec0673f", size = 6710772, upload-time = "2026-07-08T12:35:01.33Z" }, + { url = "https://files.pythonhosted.org/packages/88/9b/69e2d1627398b964f34437dc476a5aff5a2cc8e7f247d26272b5674b5faf/grpcio-1.82.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1ecfde669cb687ac020d31ff76debe5dc7a62213335f02262eb6625628da1c03", size = 7450677, upload-time = "2026-07-08T12:35:03.926Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e7/8f855ca29c294956122a2a73023655b9b02602d5111dad2b9b00e7631c68/grpcio-1.82.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:011c8badee95734dee8bf05ce3464756a0ac3ebb8d443afd20c0e2b5e4640ad9", size = 6886855, upload-time = "2026-07-08T12:35:06.174Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f0/fa87e85f49925f44c479d07e58b051e69bcfef6b6d5fbc6749d140f6730a/grpcio-1.82.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b85f4564926fb23114d239392bdcae200db1e6179629edd7d7ab0ab89c96a197", size = 7501323, upload-time = "2026-07-08T12:35:08.49Z" }, + { url = "https://files.pythonhosted.org/packages/67/55/2e0b10ae1d3ef9dcc480b91dc2158f4931fc4675d3af0a2836e39b2a744f/grpcio-1.82.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2c0c8270833395644c3fe6b6a806397955a2bc0538000a19a78b90c05a6c16e0", size = 8536899, upload-time = "2026-07-08T12:35:10.975Z" }, + { url = "https://files.pythonhosted.org/packages/bd/95/a3d8b0431fa221efc51ee39d73595ede74ba82a43b7c4313192e580face2/grpcio-1.82.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2ba199205ff46c7778290fe1673c91ac8e7e45678dd5c86e9e56fa33ec8788f6", size = 7913892, upload-time = "2026-07-08T12:35:13.944Z" }, + { url = "https://files.pythonhosted.org/packages/b8/92/f2651ec704d9852a56faef394775038afba435b50ce82ab2404d119c3355/grpcio-1.82.1-cp312-cp312-win32.whl", hash = "sha256:06127691866e295c14e84a1fb86356dd962254f6abd0da4ca4b001eea9e89438", size = 4240985, upload-time = "2026-07-08T12:35:16.048Z" }, + { url = "https://files.pythonhosted.org/packages/96/4f/a5fe8bf0d0a1b24855f370293075c931f27de4eb55f0f158786095bf3c11/grpcio-1.82.1-cp312-cp312-win_amd64.whl", hash = "sha256:1fa3223a3a2e1db74f4c2b255189eb7ea875dfba56e221d252ee3fc7b204778e", size = 5001580, upload-time = "2026-07-08T12:35:18.689Z" }, + { url = "https://files.pythonhosted.org/packages/1b/3e/496992d08c0aaa11272eb6228dc8ab947da01fe835de243cd00521bce4c4/grpcio-1.82.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:b454a2d97bfab7565683a02345f86bd182ab69fd7c2bdb7414171e7538f266b1", size = 6146068, upload-time = "2026-07-08T12:35:21.365Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8f/f263d6f14fdba6b56cfadd91fd3e158a52682b72c6016d1f8723d435659f/grpcio-1.82.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:3dde70abfc80b3be11de53ba0d601c439e7fb2afd3583ad1788d1146bec92fdc", size = 11948600, upload-time = "2026-07-08T12:35:24.312Z" }, + { url = "https://files.pythonhosted.org/packages/8c/14/3a02e6ee49c2d85bc15eaae321e0e11ab3542cad3c5b2de121ecce0c4296/grpcio-1.82.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5523099c98c292ea1ae08e617249db760c56a78f8deae879027fe7d1ffbcbf6", size = 6714591, upload-time = "2026-07-08T12:35:27.027Z" }, + { url = "https://files.pythonhosted.org/packages/69/80/58e3738696f48ab7645347b98d8a7f93d10e00e6218388fbfcd6c9310e3d/grpcio-1.82.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5e5c4dc0a59b0f8490a6bdfd6fc8395b9d8ad8a8407c7d67ca7b5bba15c0877f", size = 7454995, upload-time = "2026-07-08T12:35:29.599Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6c/2557c1a889363072fbf2285ecd0e8c44860d4dbd60f017a32537c5b863e2/grpcio-1.82.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c40d94ba820329cc191981bc22fa6f6eed0799c6d921f3c6709521d59d4a2fd7", size = 6888621, upload-time = "2026-07-08T12:35:32.38Z" }, + { url = "https://files.pythonhosted.org/packages/d2/66/907706ccaff1223f1e10fd5b37fc16faead43392fccb4e786e7e390ac141/grpcio-1.82.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c816180e31e273caaec6f8bd86a8392499d5bbb26f41da44e3dce48bde69095", size = 7505069, upload-time = "2026-07-08T12:35:35.072Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7c/ff97b0d0f635987ee5ec80dfedafa1aad629303745d48e8637d10eec5b80/grpcio-1.82.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e31fd780b261830720cb70b0fd8f0aa51d49e75a66d7464ad2e31d4b765f2580", size = 8535384, upload-time = "2026-07-08T12:35:37.954Z" }, + { url = "https://files.pythonhosted.org/packages/62/9e/a97fddd970a8d1588cade06eca20443761c1858b0ad6590a5c835aa18062/grpcio-1.82.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d76152d7c31d7210d4a106e5d8b64da5bba5d6abf11be30e2f7b0a0c59bbcbf", size = 7910707, upload-time = "2026-07-08T12:35:40.797Z" }, + { url = "https://files.pythonhosted.org/packages/20/e4/eaba1517888af483a88d449eb7566f0f7f63446d46f339c5891798435875/grpcio-1.82.1-cp313-cp313-win32.whl", hash = "sha256:38e9dcb5258226fb3282630b31b16a968df52c8c6ad514af540646e0a4578f8a", size = 4240363, upload-time = "2026-07-08T12:35:43.298Z" }, + { url = "https://files.pythonhosted.org/packages/b0/42/66a98d47732e35290bef722f6149fed3709cd4cf61166f6f53a12f417302/grpcio-1.82.1-cp313-cp313-win_amd64.whl", hash = "sha256:3dbfb52c36d9511ac2b8e6c94fdde837b393ae520cc321f52a333a2deedf5a90", size = 5000980, upload-time = "2026-07-08T12:35:46.262Z" }, + { url = "https://files.pythonhosted.org/packages/b4/cb/cf9ae9e164c6e6dc8a494faa9771763df9da150eefe19671009624d1559f/grpcio-1.82.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:35f990f7784c8fd2872644f07f96ebb4d9e48e145a190ab80d0280af91a1bfb2", size = 6146901, upload-time = "2026-07-08T12:35:49.261Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/eccf26dbcfb7f7cab8027c5490a16c8937c5aa7a2ec20a3eab2cf7a43165/grpcio-1.82.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:46536a4a1f4434df3c851b9254ff6fc7df5705b273681a15ca277d5921c178a0", size = 11954756, upload-time = "2026-07-08T12:35:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/ad/75/3b3b4a3cc9f084b026af96e1d3e539b1af29ec7f41ed0dfff3cb99cc8626/grpcio-1.82.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d6650a7c1ebb7921c70e12a385439a8118efb99e669fa9ed31cf25db1843937c", size = 6723087, upload-time = "2026-07-08T12:35:54.973Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8b/b0f0c9b1400a99a4da4c09b114f101b192f8f11192e76f620b8962f5d90b/grpcio-1.82.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b8e110c66df5204c0506d6c8787b35d48b8b699ef5aa366d6c4d67325c67fe9a", size = 7454542, upload-time = "2026-07-08T12:35:57.586Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bd/428e38868382aa193697a5aa53973f29c58e58ba4268aa0c86a2715ee58b/grpcio-1.82.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f853eae07235a51a27bb5d6a9a175a59ca55dc9b99edc6ce2f76f07332d333ae", size = 6889588, upload-time = "2026-07-08T12:36:00.012Z" }, + { url = "https://files.pythonhosted.org/packages/49/ce/03e01d5e10259bf5c08ee50570cc94724e79c956f61fd2f09b341af0956c/grpcio-1.82.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:60b0f2c95337694fc094b77d9f60f50566c84b5677393e342eb98daeee242d98", size = 7514166, upload-time = "2026-07-08T12:36:02.693Z" }, + { url = "https://files.pythonhosted.org/packages/ff/59/278b4b600329e2ba3849f3c1ea3c820b3a01b38a7ad184ba09595e8d2733/grpcio-1.82.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:b064fc444812bdaa9825d33c26f8d732d63ee6a5d78557c1faf92c98687fed27", size = 8536166, upload-time = "2026-07-08T12:36:05.349Z" }, + { url = "https://files.pythonhosted.org/packages/44/27/7ccf2ef00f27a8e47a79d641c8ceaf7d3028c7a03d9a97b4c8a9a783c086/grpcio-1.82.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7d7ede11d747b4e1bd05e3bc0260e155b65a88735a895a10f6521f19b889511e", size = 7912572, upload-time = "2026-07-08T12:36:08.393Z" }, + { url = "https://files.pythonhosted.org/packages/0d/be/33742482d2753f2d3a1b7641664b6622262d44f2f3b609f13425dd86d36f/grpcio-1.82.1-cp314-cp314-win32.whl", hash = "sha256:3d21f19838dc255ecbb79321b15ae9b98fbddff4c3d4aedb0a81bdd7f4ab572a", size = 4321856, upload-time = "2026-07-08T12:36:10.899Z" }, + { url = "https://files.pythonhosted.org/packages/cc/67/03329c847172c78ddeb1eb9be6b444fdbc12775a84c958b27e427e7b926d/grpcio-1.82.1-cp314-cp314-win_amd64.whl", hash = "sha256:e20f1edbb15f99e3128ec86433f9785fd5a451d8f115e74fe0056134f092a9d5", size = 5141114, upload-time = "2026-07-08T12:36:13.595Z" }, +] + +[[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 = "license-expression" +version = "30.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boolean-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz", hash = "sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd", size = 186402, upload-time = "2025-07-22T11:13:32.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" }, +] + +[[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 = "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 = "msgpack" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647", size = 183960, upload-time = "2026-06-18T16:13:52.594Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/6b/e9b1cdc042c4458801d2545ed782a95f3d6ba8e270cce8745b8603c7f748/msgpack-1.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:29a3f6e9667868429d8240dfd063ea5ffdc1321c13d783aa23827a38de0dcb22", size = 82812, upload-time = "2026-06-18T16:12:45.022Z" }, + { url = "https://files.pythonhosted.org/packages/0c/3a/dd518a1bf78ed1e9ad8afe57307c079a00eafe4b3068932a27ca1ea56b4f/msgpack-1.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aded5bdf32609dc7987a49bbbd15a8ef096193f96dd8bbeb791de729e650acf5", size = 82739, upload-time = "2026-06-18T16:12:46.025Z" }, + { url = "https://files.pythonhosted.org/packages/70/e0/7ba9e1542bf0771a27b8b37c1316e3f95ae9d748fd765284655c476ad4ef/msgpack-1.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:146ee4e9ce80b365c6d4c47073da9da7bcec473e58194ceee5dd7620ace77e06", size = 414233, upload-time = "2026-06-18T16:12:47.029Z" }, + { url = "https://files.pythonhosted.org/packages/03/8d/671d81534ea0e2b0e8a121be100020da09eb78861fe3aa8f3ef7dcd3bed1/msgpack-1.2.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a28d076ca7c82b9c8728ad90b7147489449557038bed50e4241eb832395169b4", size = 423843, upload-time = "2026-06-18T16:12:48.19Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b6/e5c737515ed1f166664b87601b532f58cbb73d8aa6a90b99f7c2c5037e8e/msgpack-1.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d31c0ac0c640f877804c67cb2bc9f4e23dc2db97e96c2e67fa27d38283b41f8", size = 390772, upload-time = "2026-06-18T16:12:49.624Z" }, + { url = "https://files.pythonhosted.org/packages/a8/46/62ed8c2e87d7021eab19921594d961ef3aa3794eec76c716dc30f3bfd433/msgpack-1.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ff92d7feeaf5bc26c51495b69e2f99ed97ab79346fb6555f44be7dd2ac6503b", size = 409559, upload-time = "2026-06-18T16:12:50.936Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/59aa3887b860bbf43532835e192b1c388a17590d6068ae4f8b2bc74c906e/msgpack-1.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:779197a6513bab3c3632265e3d0f7cb3227e62510841a6f34f1eaa37efbb345e", size = 387838, upload-time = "2026-06-18T16:12:52.161Z" }, + { url = "https://files.pythonhosted.org/packages/09/11/f8563e471093420cf6478cb3271a0175d8402b82d879783d4035d2d03360/msgpack-1.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:67f6dd22fa72a93752643f07889796d62739a13415ee630169a8ce764f86cf9f", size = 421732, upload-time = "2026-06-18T16:12:53.556Z" }, + { url = "https://files.pythonhosted.org/packages/57/cf/e673683c4c6c90c1022b24c65af4b03eda72b182a1176ef6449069d66acc/msgpack-1.2.1-cp311-cp311-win32.whl", hash = "sha256:91054a783328e0ea7954b8771095705c8d2243b814743fbaadf14552c9c52c5d", size = 64091, upload-time = "2026-06-18T16:12:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/3f/07/ca212739d179f9083bff2c7c08c24101c3555a334fadc2b876b18768a3ae/msgpack-1.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2eda0b7ebb1283a98d3e4492ac933c8af6aff59fd3df1c3ed024f536af4b1dc8", size = 70462, upload-time = "2026-06-18T16:12:55.898Z" }, + { url = "https://files.pythonhosted.org/packages/6d/be/6798347b425e26f35db82e69dd83c09716c856a3714e7bffc4c0860fd830/msgpack-1.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ee967f7c7e1df2890c671ff2ee51a28ded0efc95da3e507176dee881ce36c66", size = 65059, upload-time = "2026-06-18T16:12:57.053Z" }, + { url = "https://files.pythonhosted.org/packages/bc/dd/9e8cbd8f5582ca4b590336f2b91ee5662f6a6ca562b565abaf696a0f81ff/msgpack-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35", size = 83531, upload-time = "2026-06-18T16:12:58.249Z" }, + { url = "https://files.pythonhosted.org/packages/50/2e/ebdb85a8da151397a2790363676b7ed7c125924fe618e4c6d8befb0cc62c/msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c", size = 82657, upload-time = "2026-06-18T16:12:59.396Z" }, + { url = "https://files.pythonhosted.org/packages/26/aa/753ad8b007b464e1d8aa0c8e650b9c5f4f725e658fc5ac8a7635c55b7f6e/msgpack-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0", size = 410634, upload-time = "2026-06-18T16:13:00.383Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/6adabd4f6d5e686f97dd02ce7fce3fe4cf672cbac36b8f67ff4040e8ad8b/msgpack-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a", size = 419989, upload-time = "2026-06-18T16:13:01.776Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cc/85039b7b0eb168aaad7383a23c97e291a11f08351cb45a606ce865e4e3f1/msgpack-1.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6", size = 377544, upload-time = "2026-06-18T16:13:03.637Z" }, + { url = "https://files.pythonhosted.org/packages/ed/bf/35963899493b32030c85fc513b723ae66144ac70c11ebc52e889e16e3d99/msgpack-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a", size = 400842, upload-time = "2026-06-18T16:13:05.012Z" }, + { url = "https://files.pythonhosted.org/packages/a6/df/8e2ac970c8f99264cd9997d1c73df5466bc19da3301d7dc5500862a9b089/msgpack-1.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1", size = 374108, upload-time = "2026-06-18T16:13:06.232Z" }, + { url = "https://files.pythonhosted.org/packages/17/dd/fa8bd265110dfa51c20cb529f9e6d240a16fafe7e645004c6af2d01353ba/msgpack-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64", size = 414939, upload-time = "2026-06-18T16:13:07.478Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b9/8377a5ad8953fc0437c70cc98d9ae29f27fe5ac5109fbec0812085865735/msgpack-1.2.1-cp312-cp312-win32.whl", hash = "sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac", size = 64504, upload-time = "2026-06-18T16:13:08.822Z" }, + { url = "https://files.pythonhosted.org/packages/57/7f/ce1e377df7e62461fefd9eb23bfb93a4a523f40a517b377b8f844d836828/msgpack-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24", size = 71421, upload-time = "2026-06-18T16:13:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/8f/32/ebfe84c9929f08f188d56c7a2fd913406a9ddad76a634697c1c43b8112e6/msgpack-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07", size = 64775, upload-time = "2026-06-18T16:13:11.056Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ac/dcddcab6f6c20ecb387ca5e980371cdb3f87ff69aeca388be97eebc4c074/msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064", size = 83151, upload-time = "2026-06-18T16:13:12.173Z" }, + { url = "https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056", size = 82351, upload-time = "2026-06-18T16:13:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/e3/10/ddf7b06db879e8792d13934ddda09ff20bd2a583fd84c9b59aae9b0e650b/msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc", size = 407518, upload-time = "2026-06-18T16:13:14.233Z" }, + { url = "https://files.pythonhosted.org/packages/79/d3/36a46a8ed992b781acbc05928bd5bee3c810cb0c3563bf81a7b0c04a1a76/msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d", size = 416405, upload-time = "2026-06-18T16:13:15.435Z" }, + { url = "https://files.pythonhosted.org/packages/f9/84/e8e9598b557c0ba6ddae901a73780a4c75ac667dddf59414b1e56a42fb34/msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155", size = 376257, upload-time = "2026-06-18T16:13:17.022Z" }, + { url = "https://files.pythonhosted.org/packages/40/16/738fe6d875ad7e2a9429c165322a4ec088f4f273cdfae63d96a89c467961/msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402", size = 397469, upload-time = "2026-06-18T16:13:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/ca/be/6d5952df75a7f24f35833af764c3a6860780364cb3a0030beb8099e1b2b4/msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c", size = 372802, upload-time = "2026-06-18T16:13:19.685Z" }, + { url = "https://files.pythonhosted.org/packages/e1/39/e2ef7dbf0473bcb8dc7c50bf782a892d67414877b63e47fc88eb189ef5e6/msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6", size = 411273, upload-time = "2026-06-18T16:13:21.028Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c5/133f4512a56e983a93445c836c9d94d88f3bc2e0980ff4b9e577bd8416ce/msgpack-1.2.1-cp313-cp313-win32.whl", hash = "sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707", size = 64471, upload-time = "2026-06-18T16:13:22.293Z" }, + { url = "https://files.pythonhosted.org/packages/e2/98/577e10b055096a7dd40732358cabaf7180a20c79ed1dcdbb618e4b9deac7/msgpack-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9", size = 71274, upload-time = "2026-06-18T16:13:23.455Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ee/0c0048e7cfbef23c6a94791b8959ab28155232e7956de8a305b5ff588f05/msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a", size = 64795, upload-time = "2026-06-18T16:13:24.687Z" }, + { url = "https://files.pythonhosted.org/packages/77/58/cce442852c6b9e1639c7c8ac8fd9143121cb32dab0f308df4d1426a8eb9c/msgpack-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d", size = 83610, upload-time = "2026-06-18T16:13:25.724Z" }, + { url = "https://files.pythonhosted.org/packages/60/5c/15b4c7a0182f75ffa90751958ba36a9c01cafee367d49a3edc10ed140b01/msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7", size = 83138, upload-time = "2026-06-18T16:13:26.781Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/99e58722feaffc5f2fbcc0c8c0d1451ab9f84097f7af87291b46af2390f4/msgpack-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889", size = 406090, upload-time = "2026-06-18T16:13:28.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/03/8c63e8cf52958534ef688625965ab04c269a6cadd8caef16758b380a821a/msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720", size = 412106, upload-time = "2026-06-18T16:13:29.427Z" }, + { url = "https://files.pythonhosted.org/packages/63/d2/155d9e71b40e41fd934bc0c48b9b2770f22263e1ac20aad8e29fdca7be3f/msgpack-1.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6c4be5d1c02a42b066ca6ddb71adf36432868fdcdb6ee87e634e86e0674190", size = 374851, upload-time = "2026-06-18T16:13:30.631Z" }, + { url = "https://files.pythonhosted.org/packages/98/48/deaf2326262a8d5ea3295ce9649912ecd3f551ba7ec8e33c665d2ba583f3/msgpack-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec0e675d59150a6269ddc9139087c722292664a37d071a849c05c473350f1f2d", size = 396168, upload-time = "2026-06-18T16:13:31.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/2a/b4410f906c2ec0008f1608d3ab5143afc3ad3f4e6da0fed3ea2231d0bef4/msgpack-1.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:dd3bfe82d53edfe4b7fc9a7ec9761e23a7a5b1dac22264505af428253c29ed24", size = 371959, upload-time = "2026-06-18T16:13:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/59/86/1edc67270099a528fa2093ea60fe191233cd238e4bd30cfacf7db79fc959/msgpack-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ad5467fc3f68b5468e06c5f788d712e9f8ffc8b0cd1bcb160c105c1ee92dae7", size = 408457, upload-time = "2026-06-18T16:13:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/82/90/8b630fef07d8c5ab457b71ff2c217910c83d333c7a68472c186e87cc504a/msgpack-1.2.1-cp314-cp314-win32.whl", hash = "sha256:98b58bdb89c46190e4609bb36abe17c6d4105ad13f9c5f8f6f64d320f8ced3fb", size = 65942, upload-time = "2026-06-18T16:13:36.056Z" }, + { url = "https://files.pythonhosted.org/packages/16/f1/467b81e98b24dd3885d7b1857728797b4ffc76a7a7483af4fb321a07de3c/msgpack-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b", size = 72627, upload-time = "2026-06-18T16:13:37.079Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1d/5d8c4c89985feb6acefb82a09e501c60392261856d2408d20bfe4f0360b1/msgpack-1.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:b50b727bd652bdc37d950336c848ef20ec54a4cafc38dce19b1cd86ad625d0f7", size = 66908, upload-time = "2026-06-18T16:13:38.23Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ad2afb678b4de94496cd432b581759b756a92c1192d8c767edd6b132efdc/msgpack-1.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8d00f177ca88a77c1cf848d204a38f249751650b601cb6532acc68805d8a8273", size = 86000, upload-time = "2026-06-18T16:13:39.44Z" }, + { url = "https://files.pythonhosted.org/packages/54/74/0b797484013128837f3b1cbb6cea019277c4de4e377dc512b4d9a0f92940/msgpack-1.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5bb9c386f0a329c035ddbab4b72d1028bf9627add8dda41070288563d57ed1b1", size = 86544, upload-time = "2026-06-18T16:13:40.447Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b4/b774d7eb95561739907fec675582f83203cf41c597a418c2589b4bfb8e9d/msgpack-1.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20466cca18c49c7292a8984bc15d65857b171e7264bdcb5f96baf8be238791fc", size = 427661, upload-time = "2026-06-18T16:13:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f9/3243191dc9937e00756c8bc1b0272fed8f23758e43df2a3b46f533e5090f/msgpack-1.2.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:196300e7e5d6e74d50f1607ab9c06c4a1484c383cd22defd727902591f7e8dde", size = 426375, upload-time = "2026-06-18T16:13:42.936Z" }, + { url = "https://files.pythonhosted.org/packages/23/c7/1693111db9944ba4ad4b67a1e788400d78a0b6af7a6523dc7e4e58f8274b/msgpack-1.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575957e79cd51903a4e8495a242442949641e08f1efd5197b43bebd3ea7682b4", size = 380495, upload-time = "2026-06-18T16:13:44.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2b/92f86956a0c13e8662f7e2ad630c4eb4db07497b967589bd5245e018b2c1/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c2ed1e48cc0f460bf3c7780e7137ff21a4e18433451916f2442c1b21036cd7d", size = 410897, upload-time = "2026-06-18T16:13:45.629Z" }, + { url = "https://files.pythonhosted.org/packages/da/ea/1479f72d200313a76fc2f823a79d1e07ed052ab7b8a0280640aa7b95de42/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f6277e5f783c36786a145e0247fc189a03f35f84b251646e53592d2bc12b355", size = 378519, upload-time = "2026-06-18T16:13:46.998Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4d/fa006060ffa1011d32bfae826fe766fe73e02982183601633b7121058ab3/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9389552ecf4784886345ead0647e4edc96bee37cbab05b75540f542f766c48c", size = 419815, upload-time = "2026-06-18T16:13:48.205Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/aab6c946570496b78e67804721f3d5e2d62a93081b9b37df77764ef56347/msgpack-1.2.1-cp314-cp314t-win32.whl", hash = "sha256:c1c79a604a2969a868a78b6ebd27a887e00c624f14f66b3038e0590cb23332d1", size = 70914, upload-time = "2026-06-18T16:13:49.385Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/e608956488a2af014cfe6e3d665e090b8ee42aa14b07f8f95b8880d66b09/msgpack-1.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f12038a35fabd52e56a3547bab42401af49a45caa6dd00b34c44de235bc93ee2", size = 77999, upload-time = "2026-06-18T16:13:50.467Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8a/27e2e57055176e366a46b85d02d68e7a5bcfbdd8474c9706375d965f24d3/msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107", size = 71160, upload-time = "2026-06-18T16:13:51.498Z" }, +] + +[[package]] +name = "packageurl-python" +version = "0.17.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/d6/3b5a4e3cfaef7a53869a26ceb034d1ff5e5c27c814ce77260a96d50ab7bb/packageurl_python-0.17.6.tar.gz", hash = "sha256:1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25", size = 50618, upload-time = "2025-11-24T15:20:17.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/2f/c7277b7615a93f51b5fbc1eacfc1b75e8103370e786fd8ce2abf6e5c04ab/packageurl_python-0.17.6-py3-none-any.whl", hash = "sha256:31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9", size = 36776, upload-time = "2025-11-24T15:20:16.962Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pip" +version = "26.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/91/47e7d486260f618783899587af63ccf7980fb60245c3e63dd4571c6b57ad/pip-26.1.2.tar.gz", hash = "sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605", size = 1840799, upload-time = "2026-05-31T17:33:58.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/95/6b5cb3461ea5673ba0995989746db58eb18b91b54dbf331e72f569540946/pip-26.1.2-py3-none-any.whl", hash = "sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab", size = 1813144, upload-time = "2026-05-31T17:33:56.772Z" }, +] + +[[package]] +name = "pip-api" +version = "0.0.34" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pip" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/f1/ee85f8c7e82bccf90a3c7aad22863cc6e20057860a1361083cd2adacb92e/pip_api-0.0.34.tar.gz", hash = "sha256:9b75e958f14c5a2614bae415f2adf7eeb54d50a2cfbe7e24fd4826471bac3625", size = 123017, upload-time = "2024-07-09T20:32:30.641Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/f7/ebf5003e1065fd00b4cbef53bf0a65c3d3e1b599b676d5383ccb7a8b88ba/pip_api-0.0.34-py3-none-any.whl", hash = "sha256:8b2d7d7c37f2447373aa2cf8b1f60a2f2b27a84e1e9e0294a3f6ef10eb3ba6bb", size = 120369, upload-time = "2024-07-09T20:32:29.099Z" }, +] + +[[package]] +name = "pip-audit" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachecontrol", extra = ["filecache"] }, + { name = "cyclonedx-python-lib" }, + { name = "packaging" }, + { name = "pip-api" }, + { name = "pip-requirements-parser" }, + { name = "platformdirs" }, + { name = "requests" }, + { name = "rich" }, + { name = "tomli" }, + { name = "tomli-w" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/a4/f21d5f0a0edabcbce31560b73c7c5a6f72ae87af4236fd1069c8f59a353d/pip_audit-2.10.1.tar.gz", hash = "sha256:1eb4565d19ebe5d48996f4b770b4d2b32887e12cb12cfa637f1a064011b55ffc", size = 54275, upload-time = "2026-06-10T22:17:01.744Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/a7/b0c504148114047bd1bc9d97447453c6850ca176bb2f3c0038835994e8b7/pip_audit-2.10.1-py3-none-any.whl", hash = "sha256:99ef3f600a317c1945f1e89e227ef26e1c2d618429b8bd3fa6f4f7c440c4611a", size = 62023, upload-time = "2026-06-10T22:17:00.309Z" }, +] + +[[package]] +name = "pip-requirements-parser" +version = "32.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/2a/63b574101850e7f7b306ddbdb02cb294380d37948140eecd468fae392b54/pip-requirements-parser-32.0.1.tar.gz", hash = "sha256:b4fa3a7a0be38243123cf9d1f3518da10c51bdb165a2b2985566247f9155a7d3", size = 209359, upload-time = "2022-12-21T15:25:22.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/d0/d04f1d1e064ac901439699ee097f58688caadea42498ec9c4b4ad2ef84ab/pip_requirements_parser-32.0.1-py3-none-any.whl", hash = "sha256:4659bc2a667783e7a15d190f6fccf8b2486685b6dba4c19c3876314769c57526", size = 35648, upload-time = "2022-12-21T15:25:21.046Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" }, +] + +[[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 = "privacy-guard" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "grpcio" }, + { name = "protobuf" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "typer" }, + { name = "typing-extensions" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pip-audit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [ + { name = "grpcio", specifier = ">=1.81.1,<2" }, + { name = "protobuf", specifier = ">=6.33.5,<7" }, + { name = "pydantic", specifier = ">=2.11,<3" }, + { name = "pyyaml", specifier = ">=6,<7" }, + { name = "regex", specifier = ">=2026.7.19,<2027" }, + { name = "typer", specifier = ">=0.16,<1" }, + { name = "typing-extensions", specifier = ">=4.12,<5" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pip-audit", specifier = "==2.10.1" }, + { name = "pytest", specifier = ">=9.0.3,<10" }, + { name = "pytest-asyncio", specifier = ">=0.25,<2" }, + { name = "ruff", specifier = ">=0.12,<0.13" }, + { name = "ty", specifier = ">=0.0.1a16,<0.1" }, +] + +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + +[[package]] +name = "py-serializable" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "defusedxml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/21/d250cfca8ff30c2e5a7447bc13861541126ce9bd4426cd5d0c9f08b5547d/py_serializable-2.1.0.tar.gz", hash = "sha256:9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103", size = 52368, upload-time = "2025-07-21T09:56:48.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/bf/7595e817906a29453ba4d99394e781b6fabe55d21f3c15d240f85dd06bb1/py_serializable-2.1.0-py3-none-any.whl", hash = "sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304", size = 23045, upload-time = "2025-07-21T09:56:46.848Z" }, +] + +[[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 = "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 = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[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 = "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 = "regex" +version = "2026.7.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/98/04b13f1ddfb63158025291c02e03eb42fbb7acb51d091d541050eb4e35e8/regex-2026.7.19.tar.gz", hash = "sha256:7e77b324909c1617cbb4c668677e2c6ae13f44d7c1de0d4f15f2e3c10f3315b5", size = 416440, upload-time = "2026-07-19T00:19:48.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/e5/cef4de2bac939280b68d32adc659478845238a8274f2f79c465063f590ad/regex-2026.7.19-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ac777001cdfc28b72477d93c8564bb7583081ea8fb45cdca3d568e0a4f87183c", size = 494012, upload-time = "2026-07-19T00:16:39.927Z" }, + { url = "https://files.pythonhosted.org/packages/ff/87/e86f51eb117457bb7803132ffe5cb6e2841e2b5bea4cc85d397f3c6e257d/regex-2026.7.19-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:59787bd5f8c70aa339084e961d2996b53fbdeab4d5393bba5c1fe1fc32e02bae", size = 295281, upload-time = "2026-07-19T00:16:41.433Z" }, + { url = "https://files.pythonhosted.org/packages/41/2e/2360c41d8080a3d9ec7e5c90fad6eab3b50192869d10e9a5609e48c8177b/regex-2026.7.19-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:90c633e7e8d6bf4e992b8b36ce69e018f834b641dd6de8cea6d78c06ffa119c5", size = 290615, upload-time = "2026-07-19T00:16:43.058Z" }, + { url = "https://files.pythonhosted.org/packages/cf/69/b65ba4344efbc771b28fe5dde84cbbb6c8f9551165952fe78def5b9dde6a/regex-2026.7.19-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87ccab0db8d5f4fbb0272642113c1adb2ffc698c16d3a0944580222331fa7a20", size = 791804, upload-time = "2026-07-19T00:16:44.662Z" }, + { url = "https://files.pythonhosted.org/packages/81/b6/a40dfa0dc6224b36f620c00296eacc830489cbf8c2837b6750dfe6170375/regex-2026.7.19-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e50d748a32da622f256e8d505867f5d3c43a837c6a9f0efb149655fadd1042a", size = 861723, upload-time = "2026-07-19T00:16:46.412Z" }, + { url = "https://files.pythonhosted.org/packages/e3/02/735991dee71abd83196a7962f7ed8bf5aa05720ff06e2d3ff896a85e2bbb/regex-2026.7.19-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf1516fe58fc104f39b2d1dbe2d5e27d0cd45c4be2e42ba6ee0cc763701ec3c7", size = 905932, upload-time = "2026-07-19T00:16:47.956Z" }, + { url = "https://files.pythonhosted.org/packages/45/6c/e7098d8b846ccdbf431d8c081b61e496526a27a28094ed09e0dce21b3f54/regex-2026.7.19-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09f3e5287f94f17b709dc9a9e70865855feee835c861613be144218ce4ca82cc", size = 801407, upload-time = "2026-07-19T00:16:49.43Z" }, + { url = "https://files.pythonhosted.org/packages/8a/18/34b69274e2649bcc7d9b089c2b2983fb2632d8ecf667e359593be9072e79/regex-2026.7.19-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6383cd2ed53a646c659ba1fe65727db76437fdaa069e697a0b44a51d5843d864", size = 774448, upload-time = "2026-07-19T00:16:51.352Z" }, + { url = "https://files.pythonhosted.org/packages/bb/e6/0a72247d025585fd3800b98e040b84d562a88af6303347100484849f4f01/regex-2026.7.19-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:09d3007fc76249a83cdd33de160d50e6cb77f54e09d8fa9e7148e10607ce24af", size = 783297, upload-time = "2026-07-19T00:16:53.071Z" }, + { url = "https://files.pythonhosted.org/packages/b1/aa/c4f65ae7dd02a36b323a70c4cff326e1f3442361aaebc9311100a130d54f/regex-2026.7.19-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6f8c6e7a1cfa3dc9d0ee2de0e65e834537fa29992cc3976ffec914afc35c5dd5", size = 854736, upload-time = "2026-07-19T00:16:54.607Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/668082bcc817b9e694189b84997aeba7385b7779faa6711788679c482e35/regex-2026.7.19-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b2ea4a3e8357be8849e833beeae757ac3c7a6b3fc055c03c808a53c91ad30d82", size = 763298, upload-time = "2026-07-19T00:16:56.289Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fb/2d07ad555e7af88aa5f867fdafa47a8d945ee237c20af3ebceb46a820835/regex-2026.7.19-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:80115dd39481fd3a4b4080220799dbcacb921a844de4b827264ececacbe17c78", size = 844430, upload-time = "2026-07-19T00:16:57.933Z" }, + { url = "https://files.pythonhosted.org/packages/51/15/c82a471fe3dce56f03745635b43aa456c40dc0db089e07ef148b331507d1/regex-2026.7.19-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d6ce43a0269d68cee79a7d1ade7def53c20f8f2a047b92d7b5d5bcc73ae88327", size = 789683, upload-time = "2026-07-19T00:16:59.583Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f4/7532a2c59d56f5398902c20de60f0c9a5d1cd364e42a051b48e1b210be7b/regex-2026.7.19-cp311-cp311-win32.whl", hash = "sha256:9be2a6647740dd3cca6acb24e87f03d7632cd280dbce9bbe40c26353a215a45d", size = 266778, upload-time = "2026-07-19T00:17:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/83/2b/cf1bc631db154eb95520d9d5dbc2371ff77a0f014bbf7d748fed8496aa63/regex-2026.7.19-cp311-cp311-win_amd64.whl", hash = "sha256:8d3469c91dd92ee41b7c95280edbd975ef1ba9195086686623a1c6e8935ce965", size = 277983, upload-time = "2026-07-19T00:17:02.571Z" }, + { url = "https://files.pythonhosted.org/packages/8d/bd/56ceaf170e875d5a6761bf2bfd0d040f1cacc896850d5e40cb29b11bbd06/regex-2026.7.19-cp311-cp311-win_arm64.whl", hash = "sha256:36aacfb15faaff3ced55afbf35ec72f50d4aee22082c4f7fe0573a33e2fca92e", size = 276961, upload-time = "2026-07-19T00:17:04.135Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b9/d11d7e501ac8fd7d617684423ebb9561e0b998481c1e4cbc0cb212c5d74a/regex-2026.7.19-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2cc3460cedf7579948486eab03bc9ad7089df4d7281c0f47f4afe03e8d13f02d", size = 496778, upload-time = "2026-07-19T00:17:05.677Z" }, + { url = "https://files.pythonhosted.org/packages/3f/a9/a5ab6f312f24318019170dc485d5421fe4f89e43a98640da50d95a8a7041/regex-2026.7.19-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0e9554c8785eac5cffe6300f69a91f58ba72bc88a5f8d661235ad7c6aa5b8ccd", size = 297122, upload-time = "2026-07-19T00:17:07.59Z" }, + { url = "https://files.pythonhosted.org/packages/b3/63/4cab4d7f2d384a144d420b763d97674cb70619c878ea6fcd7640d0e62143/regex-2026.7.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d7da47a0f248977f08e2cb659ff3c17ddc13a4d39b3a7baa0a81bf5b415430f6", size = 292009, upload-time = "2026-07-19T00:17:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/22/85/102a81b218298957d4ea7d2f084fae537a71add9d6ff93c8e67284c5f45e/regex-2026.7.19-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93db40c8de0815baab96a06e08a984bac71f989d13bab789e382158c5d426797", size = 796708, upload-time = "2026-07-19T00:17:11.542Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/dc136af5629938a037cd2b304c12240e132ec92f38be8ff9cc89af2a1f2d/regex-2026.7.19-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:66bd62c59a5427746e8c44becae1d9b99d22fb13f30f492083dfb9ad7c45cc18", size = 865651, upload-time = "2026-07-19T00:17:13.312Z" }, + { url = "https://files.pythonhosted.org/packages/e0/75/67402ae3cd9c8c988a4c805d15ee3eef015e7ca4cb112cf3e640fc1f4153/regex-2026.7.19-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1649eb39fcc9ea80c4d2f110fde2b8ab2aef3877b98f02ab9b14e961f418c511", size = 911756, upload-time = "2026-07-19T00:17:15.015Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8e/096d00c7c480ef2ff4265349b14e2261d4ab787ba1f74e2e80d1c58079c3/regex-2026.7.19-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9dce8ec9695f531a1b8a6f314fd4b393adcccf2ea861db480cdf97a301d01a68", size = 801798, upload-time = "2026-07-19T00:17:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f0/41/e7ecac6edb5722417f85cc67eaf386322fbe8acf6918ec2fdc37c20dd9d0/regex-2026.7.19-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3080a7fd38ef049bd489e01c970c97dd84ff446a885b0f1f6b26d9b1ad13ce11", size = 776933, upload-time = "2026-07-19T00:17:19.347Z" }, + { url = "https://files.pythonhosted.org/packages/6f/69/03c9b3f058d66403e0ca2c938696e81d51cd4c6d47ec5265f02f96948d9a/regex-2026.7.19-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1d793a7988e04fcb1e2e135567443d82173225d657419ec09414a9b5a145b986", size = 784338, upload-time = "2026-07-19T00:17:21.057Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f7/b38ab3d43f284afbb618fcd15d0e77eb786ae461ce1f6bc7494619ddc0f2/regex-2026.7.19-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e8b0abe7d870f53ca5143895fef7d1041a0c831a140d3dc2c760dd7ba25d4a8b", size = 860452, upload-time = "2026-07-19T00:17:23.119Z" }, + { url = "https://files.pythonhosted.org/packages/15/5c/ff60ef0571121714f3cf9920bc183071e384a10b556d042e0fdb06cc07a5/regex-2026.7.19-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4e5413bd5f13d3a4e3539ca98f70f75e7fca92518dd7f117f030ebedd10b60cb", size = 765958, upload-time = "2026-07-19T00:17:24.81Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0f/bd34021162c0ab47f9a315bd56cd5642e920c8e5668a75ef6c6a6fca590d/regex-2026.7.19-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:73b133a9e6fb512858e7f065e96f1180aa46646bc74a83aea62f1d314f3dd035", size = 851765, upload-time = "2026-07-19T00:17:26.993Z" }, + { url = "https://files.pythonhosted.org/packages/2a/20/a2ca43edade0595cccfdc98636739f536d9e26898e7dbddc2b9e98898953/regex-2026.7.19-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dbe6493fbd27321b1d1f2dd4f5c7e5bd4d8b1d7cab7f32fd67db3d0b2ed8248a", size = 789714, upload-time = "2026-07-19T00:17:28.699Z" }, + { url = "https://files.pythonhosted.org/packages/5d/47/e02db4015d424fc83c00ea0ac8c5e5ec14397943de9abf909d5ce3a25931/regex-2026.7.19-cp312-cp312-win32.whl", hash = "sha256:ddd67571c10869f65a5d7dde536d1e066e306cc90de57d7de4d5f34802428bb5", size = 267157, upload-time = "2026-07-19T00:17:31.051Z" }, + { url = "https://files.pythonhosted.org/packages/08/8e/c780c131f79b42ed22d1bd7da4096c2c35f813e835acd02ef0f018bd892c/regex-2026.7.19-cp312-cp312-win_amd64.whl", hash = "sha256:e30d40268a28d54ce0437031750497004c22602b8e3ab891f759b795a003b312", size = 277777, upload-time = "2026-07-19T00:17:32.848Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4c/e4d7e086449bdf379d89774bf1f89dc4a41943f3c5a6125a03905b34b5fb/regex-2026.7.19-cp312-cp312-win_arm64.whl", hash = "sha256:de9208bb427130c82a5dbfd104f92c8876fc9559278c880b3002755bbbe9c83d", size = 277136, upload-time = "2026-07-19T00:17:34.803Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3d/84165e4299ff76f3a40fe1f2abf939e976f693383a08d2beea6af62bd2c1/regex-2026.7.19-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f035d9dc1d25eff9d361456572231c7d27b5ccd473ca7dc0adfce732bd006d40", size = 496552, upload-time = "2026-07-19T00:17:36.808Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/a65293e6e4cf28eb7ee1be5335a5386c40d6742e9f47fafc8fec785e16c7/regex-2026.7.19-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c42572142ed0b9d5d261ba727157c426510da78e20828b66bbb855098b8a4e38", size = 296983, upload-time = "2026-07-19T00:17:38.816Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/2d0564e93d87bc48618360ddca232a2ca612bbdf53ce8465d45ca5ce14ee/regex-2026.7.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:40b34dd88658e4fedd2fddbf0275ac970d00614b731357f425722a3ed1983d11", size = 291832, upload-time = "2026-07-19T00:17:40.726Z" }, + { url = "https://files.pythonhosted.org/packages/07/cd/42dfbabff3dfc9603c501c0e2e2c5adbb09d127b267bf5348de0af338c15/regex-2026.7.19-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c41c63992bf1874cebb6e7f56fd7d3c007924659a604ae3d90e427d40d4fd13", size = 796775, upload-time = "2026-07-19T00:17:42.382Z" }, + { url = "https://files.pythonhosted.org/packages/df/5d/f6a4839f2b934e3eed5973fd07f5929ee97d4c98939fb275ea23c274ee16/regex-2026.7.19-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d3372064506b94dd2c67c845f2db8062e9e9ba84d04e33cb96d7d33c11fe1ae", size = 865687, upload-time = "2026-07-19T00:17:44.185Z" }, + { url = "https://files.pythonhosted.org/packages/14/b0/b47d6c36049bc59806a50bd4c86ced70bbe058d787f80281b1d7a9b0e024/regex-2026.7.19-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fce7760bf283405b2c7999cab3da4e72f7deca6396013115e3f7a955db9760da", size = 911962, upload-time = "2026-07-19T00:17:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/2a/be/ff61f28f9273658cfe23acbbac5217221f6519960ed401e61dfdab12bc35/regex-2026.7.19-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0d702548d89d572b2929879bc883bb7a4c4709efafe4512cadee56c55c9bd15", size = 801817, upload-time = "2026-07-19T00:17:48.25Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bb/8b4f7f26b333f9f79e1b453613c39bb4776f51d38ae66dd0ba31d6b354ca/regex-2026.7.19-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d446c6ac40bb6e05025ccee55b84d80fe9bf8e93010ffc4bb9484f13d498835f", size = 776908, upload-time = "2026-07-19T00:17:50.183Z" }, + { url = "https://files.pythonhosted.org/packages/09/13/610110fc5921d380516d03c26b652555f08aa0d23ea78a771231873c3638/regex-2026.7.19-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c3501bfa814ab07b5580741f9bf78dfdfe146a04057f82df9e2402d2a975939", size = 784426, upload-time = "2026-07-19T00:17:52.454Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f5/1ef9e2a83a5947c57ebff0b377cb5727c3d5ec1992317a320d035cd0dbb6/regex-2026.7.19-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c4585c3e64b4f9e583b4d2683f18f5d5d872b3d71dcf24594b74ecc23602fa96", size = 860600, upload-time = "2026-07-19T00:17:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/a0/02/073af33a3ec149241d11c80acea91e722aa0adbf05addd50f251c4fe89c3/regex-2026.7.19-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:571fde9741eb0ccde23dd4e0c1d50fbae910e901fa7e629faf39b2dda740d220", size = 765950, upload-time = "2026-07-19T00:17:56.041Z" }, + { url = "https://files.pythonhosted.org/packages/81/a9/d1e9f819dc394a568ef370cd56cf25394e957a2235f8370f23b576e5a475/regex-2026.7.19-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:15b364b9b98d6d2fe1a85034c23a3180ff913f46caddc3895f6fd65186255ccc", size = 851794, upload-time = "2026-07-19T00:17:57.897Z" }, + { url = "https://files.pythonhosted.org/packages/03/3a/8ae83eda7579feacdf984e71fb9e70635fb6f832eeddca58427ec4fca926/regex-2026.7.19-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffd8893ccc1c2fce6e0d6ca402d716fe1b29db70c7132609a05955e31b2aa8f2", size = 789845, upload-time = "2026-07-19T00:17:59.97Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/c195cbfe5a75fdec64d8f6554fd15237b837919d2c61bdc141d7c807b08b/regex-2026.7.19-cp313-cp313-win32.whl", hash = "sha256:f0fa4fa9c3632d708742baf2282f2055c11d888a790362670a403cbf48a2c404", size = 267135, upload-time = "2026-07-19T00:18:01.958Z" }, + { url = "https://files.pythonhosted.org/packages/b2/80/a11de8404b7272b70acb45c1c05987cce60b45d5693da2e176f0e390d564/regex-2026.7.19-cp313-cp313-win_amd64.whl", hash = "sha256:d51ffd3427640fa2da6ade574ceba932f210ad095f65fcc450a2b0a0d454868e", size = 277747, upload-time = "2026-07-19T00:18:04.121Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/0f5c8eff1b4f1f3d83276d365fccecf666afcc7d947420943bf394d07adb/regex-2026.7.19-cp313-cp313-win_arm64.whl", hash = "sha256:c670fe7be5b6020b76bc6e8d2196074657e1327595bca93a389e1a76ab130ad8", size = 277129, upload-time = "2026-07-19T00:18:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4c/44b74742052cedda40f9ae469532a037112f7311a36669a891fba8984bb0/regex-2026.7.19-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db47b561c9afd884baa1f96f797c9ca369872c4b65912bc691cfa99e68340af2", size = 501134, upload-time = "2026-07-19T00:18:07.567Z" }, + { url = "https://files.pythonhosted.org/packages/f0/45/bbd038b5e39ee5613a5a689290145b40058cc152c41de9cc23639d2b9734/regex-2026.7.19-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:65dcd28d3eba2ab7c2fd906485cc301392b47cc2234790d27d4e4814e02cdfda", size = 299418, upload-time = "2026-07-19T00:18:09.38Z" }, + { url = "https://files.pythonhosted.org/packages/65/38/c5bde94b4cedfd5850d64c3f08222d8e1600e84f6ee71d9b44b4b8163f74/regex-2026.7.19-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f2e7f8e2ab6c2922be02c7ec45185aa5bd771e2e57b95455ee343a44d8130dff", size = 294486, upload-time = "2026-07-19T00:18:11.188Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6a/2f5e107cb26c960b781967178899daf2787a7ab151844ed3c01d6fc95474/regex-2026.7.19-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe31f28c94402043161876a258a9c6f757cb485905c7614ce8d6cd40e6b7bdc1", size = 811643, upload-time = "2026-07-19T00:18:12.975Z" }, + { url = "https://files.pythonhosted.org/packages/37/d4/a2f963406d7d73a62eed84ba05a258afb6cad1b21aa4517443ce40506b78/regex-2026.7.19-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8f6fa298bb4f7f58a33334406218ba74716e68feddf5e4e54cd5d8082705abf", size = 871081, upload-time = "2026-07-19T00:18:14.733Z" }, + { url = "https://files.pythonhosted.org/packages/45/a3/44be546340bedb15f13063f5e7fe16793ea4d9ea2e805d09bd174ac27724/regex-2026.7.19-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cc1b2440423a851fad781309dd87843868f4f66a6bcd1ddb9225cf4ec2c84732", size = 917372, upload-time = "2026-07-19T00:18:16.724Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f6/e0870b0fd2a40dba0074e4b76e514b21313d37946c9248453e34ec43923e/regex-2026.7.19-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ac59a0900474a52b7c04af8196affc22bd9842acb0950df12f7b813e983609a", size = 816089, upload-time = "2026-07-19T00:18:18.617Z" }, + { url = "https://files.pythonhosted.org/packages/ae/27/957e8e22690ad6634572b39b71f130a6105f4d0718bb16849eac00fff147/regex-2026.7.19-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4896db1f4ce0576765b8272aa922df324e0f5b9bb2c3d03044ff32a7234a9aba", size = 785206, upload-time = "2026-07-19T00:18:20.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/a4/186e410941e731037c01166069ab86da9f65e8f8110c18009ccf4bd623ee/regex-2026.7.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4e6883a021db30511d9fb8cfb0f222ce1f2c369f7d4d8b0448f449a93ba0bdfc", size = 800431, upload-time = "2026-07-19T00:18:22.716Z" }, + { url = "https://files.pythonhosted.org/packages/73/9f/e4e10e023d291d64a33e246610b724493bf1ce98e0e59c9b7c837e5acfb7/regex-2026.7.19-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:09523a592938aa9f587fb74467c63ff0cf88fc3df14c82ab0f0517dcf76aaa62", size = 864906, upload-time = "2026-07-19T00:18:24.772Z" }, + { url = "https://files.pythonhosted.org/packages/24/57/ccb20b6be5f1f52a053d1ba2a8f7a077edb9d918248b8490d7506c6832b3/regex-2026.7.19-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1ebac3474b8589fce2f9b225b650afd61448f7c73a5d0255a10cc6366471aed1", size = 773559, upload-time = "2026-07-19T00:18:27.008Z" }, + { url = "https://files.pythonhosted.org/packages/a3/82/f3b263cf8fad927dc102891da8502e718b7ff9d19af7a2a07c03865d7188/regex-2026.7.19-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4a0530bb1b8c1c985e7e2122e2b4d3aedd8a3c21c6bfddae6767c4405668b56e", size = 857739, upload-time = "2026-07-19T00:18:29.107Z" }, + { url = "https://files.pythonhosted.org/packages/47/2e/1687bd1b6c2aed5e672ccf845fc11557821fe7366d921b50889ea5ce57bf/regex-2026.7.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef7eeb108c47ce7bcc9513e51bcb1bf57e8f483d52fce68a8642e3527141ae0", size = 804522, upload-time = "2026-07-19T00:18:31.362Z" }, + { url = "https://files.pythonhosted.org/packages/76/7c/cc4e7655181b2d9235b704f2c5e19d8eff002bbc437bae59baee0e381aca/regex-2026.7.19-cp313-cp313t-win32.whl", hash = "sha256:64b6ca7391a1395c2638dd5c7456d67bea44fc6c5e8e92c5dc8aa6a8f23292b4", size = 269141, upload-time = "2026-07-19T00:18:33.479Z" }, + { url = "https://files.pythonhosted.org/packages/bb/14/961b4c7b05a2391c32dbc85e27773076671ef8f97f36cec70fe414734c02/regex-2026.7.19-cp313-cp313t-win_amd64.whl", hash = "sha256:f04b9f56b0e0614c0126be12c2c2d9f8850c1e57af302bd0a63bed379d4af974", size = 280036, upload-time = "2026-07-19T00:18:35.419Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/795644550d788ddbb6dc458c95895f8009978ea6d6ea76b005eb3f45e8c9/regex-2026.7.19-cp313-cp313t-win_arm64.whl", hash = "sha256:fcee38cd8e5089d6d4f048ba1233b3ad76e5954f545382180889112ff5cb712d", size = 279394, upload-time = "2026-07-19T00:18:37.454Z" }, + { url = "https://files.pythonhosted.org/packages/d2/25/0c4c452f8ef3efe456745b2f33195f5904b573fb4c2ff3f0cb9ec188461e/regex-2026.7.19-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a81758ed242b861b72e778ba34d41366441a2e10b16b472784c88da2dea7e2dd", size = 496750, upload-time = "2026-07-19T00:18:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/24/9e/b70ca6c1704f6c7cd32a9e143c86cc5968d10981eca284bad670c245ea7d/regex-2026.7.19-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4aa5435cdb3eb6f55fe98a171b05e3fbcd95fadaa4aa32acf62afd9b0cfdbcac", size = 297093, upload-time = "2026-07-19T00:18:41.583Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/0b692da2520d51fbff19c88b83d97e4c702909dd02386c585998b7e2dbed/regex-2026.7.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:60be8693a1dadc210bbcbc0db3e26da5f7d01d1d5a3da594e99b4fa42df404f5", size = 292043, upload-time = "2026-07-19T00:18:43.347Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a7/1d478e614016045a33feae57446215f9fd65b665a5ceb2f891fb3183bc52/regex-2026.7.19-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19662dbedbe783d323196312d38f5ba53cf56296378252171985da6899887d3", size = 797214, upload-time = "2026-07-19T00:18:45.362Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ae/11b9c9411d92c30e3d2db32df5a31133e4a99a8fc397a604fd08f6c4bffb/regex-2026.7.19-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d15df07081d91b76ff20d43f94592ee110330152d617b730fdbe5ef9fb680053", size = 866433, upload-time = "2026-07-19T00:18:47.315Z" }, + { url = "https://files.pythonhosted.org/packages/b1/62/2b2efc4992f91d6d204b24c647c9f9412e85379d92b7c0ab9fdae622327e/regex-2026.7.19-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:56ad4d9f77df871a99e25c37091052a02528ec0eb059de928ee33956b854b45b", size = 911360, upload-time = "2026-07-19T00:18:49.588Z" }, + { url = "https://files.pythonhosted.org/packages/14/71/986ceea9aa3da548bf1357cad89b63915ec6d21ec957c8113b29ece567df/regex-2026.7.19-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7322ec6cc9fba9d49ab888bb82d67ac5625627aa168f0165139b17018df3fb8a", size = 801275, upload-time = "2026-07-19T00:18:51.767Z" }, + { url = "https://files.pythonhosted.org/packages/15/be/ce9d9534b2cda96eab32c548261224b9b4e220a4126f098f60f42ae7b4cd/regex-2026.7.19-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c7472192ebfad53a6be7c4a8bfb2d64b81c0e93a1fc8c57e1dd0b638297b5d1", size = 777131, upload-time = "2026-07-19T00:18:54.053Z" }, + { url = "https://files.pythonhosted.org/packages/61/2b/58b5c710f2c3929515a25f3a1ca0dad0dcd4518d4fff3cf23bc7adb8dcd2/regex-2026.7.19-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c10b82c2634df08dfb13b1f04e38fe310d086ee092f4f69c0c8da234251e556e", size = 785020, upload-time = "2026-07-19T00:18:56.579Z" }, + { url = "https://files.pythonhosted.org/packages/84/03/5fe091935b74f15fe0f97998c215cae418d1c0413f6258c7d4d2e83aa37f/regex-2026.7.19-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:17ed5692f6acc4183e98331101a5f9e4f64d72fe58b753da4d444a2c77d05b12", size = 861263, upload-time = "2026-07-19T00:18:58.64Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fa/d60bf82e10841eef62a9e32aac401468f05fddfbcb2942e342b1ba3d2433/regex-2026.7.19-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:22a992de9a0d91bda927bf02b94351d737a0302905432c88a53de7c4b9ce62e2", size = 766199, upload-time = "2026-07-19T00:19:00.705Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5d/11e64d151b0662b81d6bf644c74dc118d461df85bdf2577fadbbf751788a/regex-2026.7.19-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:618a0aed532be87294c4477b0481f3aa0f1520f4014a4374dd4cf789b4cd2c97", size = 851317, upload-time = "2026-07-19T00:19:03.015Z" }, + { url = "https://files.pythonhosted.org/packages/7c/34/532efb87488d90807bae6a443d357ee5e2728a478c597619c8aaa17cc0bd/regex-2026.7.19-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ce9e679f776649746729b6c86382da519ef649c8e34cc41df0d2e5e0f6c36d4", size = 789557, upload-time = "2026-07-19T00:19:05.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/90/3a8d5ca977171ec3ae21a71207d2228b2663bde14d7f7ef0e6363ecf9290/regex-2026.7.19-cp314-cp314-win32.whl", hash = "sha256:73f272fba87b8ccfe70a137d02a54af386f6d27aa509fbffdd978f5947aae1aa", size = 272531, upload-time = "2026-07-19T00:19:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/96/e1/8862885e70409de70e8c005f57fb2e7be8d9ef0317250d60f4c9660a300d/regex-2026.7.19-cp314-cp314-win_amd64.whl", hash = "sha256:d721e53758b2cca74990185eb0671dd466d7a388a1a45d0c6f4c13cef41a68ac", size = 280831, upload-time = "2026-07-19T00:19:09.46Z" }, + { url = "https://files.pythonhosted.org/packages/08/82/2693e53e29f9104d9de95d37ce4dd826bd32d5f9c0085d3aa6ac042675c4/regex-2026.7.19-cp314-cp314-win_arm64.whl", hash = "sha256:65fa6cb38ed5e9c3637e68e544f598b39c3b86b808ed0627a67b68320384b459", size = 281099, upload-time = "2026-07-19T00:19:11.398Z" }, + { url = "https://files.pythonhosted.org/packages/92/b7/9a01aa16461a18cde9d7b9c3ab21e501db2ce33725f53014342b91df2b0a/regex-2026.7.19-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:5a2721c8720e2cb3c209925dfb9200199b4b07361c9e01d321719404b21458b3", size = 501121, upload-time = "2026-07-19T00:19:13.425Z" }, + { url = "https://files.pythonhosted.org/packages/f3/5e/bbaeca815dc9191c424c94a4fdc5c87c75748a64a6271821212ebdd4e1a3/regex-2026.7.19-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:199535629f25caf89698039af3d1ad5fcae7f933e2112c73f1cdf49165c99518", size = 299415, upload-time = "2026-07-19T00:19:15.43Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d6/0dd1a321afaab95eb7ff44aa0f637301786f1dc71c6b797b9ed236ed8890/regex-2026.7.19-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9b60d7814174f059e5de4ab98271cc5ba9259cfea55273a81544dceea32dc8d9", size = 294483, upload-time = "2026-07-19T00:19:17.879Z" }, + { url = "https://files.pythonhosted.org/packages/92/5f/40bacf91d0904f812e13bbbab3864604c463eced8afdc54aeaa50492ea95/regex-2026.7.19-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbece16025afda5e3031af0c4059207e61dcf73ef13af844964f57f387d1c435", size = 811833, upload-time = "2026-07-19T00:19:20.102Z" }, + { url = "https://files.pythonhosted.org/packages/94/7c/4902744261f775aeede8b5627314b38482da29cf49a57b66a6fb753246c5/regex-2026.7.19-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d24ecb4f5e009ea0bd275ee37ad9953b32005e2e5e60f8bbae16da0dbbf0d3a0", size = 871270, upload-time = "2026-07-19T00:19:22.365Z" }, + { url = "https://files.pythonhosted.org/packages/16/70/6980c9be6bf21c0a60ed3e0aea39cf419ecf3b08d1d9947bc56e196ef186/regex-2026.7.19-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8cae6fd77a5b72dae505084b1a2ee0360139faf72fedbab667cd7cc65aae7a6a", size = 917534, upload-time = "2026-07-19T00:19:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/52/92/8b2bd872782ce8c42691e39acb38eb8efe014e5ddb78ad7d943d6f197ce9/regex-2026.7.19-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9724e6cb5e478cd7d8cabf027826178739cb18cf0e117d0e32814d479fa02276", size = 816135, upload-time = "2026-07-19T00:19:26.919Z" }, + { url = "https://files.pythonhosted.org/packages/de/2d/33a602f657bdc4041f17d79f92ab18261d255d91a06117a6e29df023e5e2/regex-2026.7.19-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:572fc57b0009c735ee56c175ea021b637a15551a312f56734277f923d6fd0f6c", size = 785492, upload-time = "2026-07-19T00:19:29.192Z" }, + { url = "https://files.pythonhosted.org/packages/9e/36/0987cf4cb271680064a70d24a475873775a151d0b7058698a006cb0cae4a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:20568e182eb82d39a6bf7cff3fd58566f14c75c6f74b2c8c96537eecf9010e3a", size = 800658, upload-time = "2026-07-19T00:19:31.392Z" }, + { url = "https://files.pythonhosted.org/packages/a8/24/c14f31c135e1ba55fa4f9a58ca98d0842512bf6188230763c31c8f449e3b/regex-2026.7.19-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1d58561843f0ff7dc78b4c28b5e2dc388f3eff94ebc8a232a3adba961fc00009", size = 865073, upload-time = "2026-07-19T00:19:33.485Z" }, + { url = "https://files.pythonhosted.org/packages/14/85/181a12211f22469f24d2de1ebddfe397d2396e2c29013b9a58134a91069a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:61bb1bd45520aacd56dd80943bd34991fb5350afdd1f36f2282230fd5154a218", size = 773684, upload-time = "2026-07-19T00:19:35.599Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/bd1a0c1a62251366f8d21f41b1ea3c76994962071b8b6ea42f72d505c0f0/regex-2026.7.19-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:cd3584591ea4429026cdb931b054342c2bcf189b44ff367f8d5c15bc092a2966", size = 857769, upload-time = "2026-07-19T00:19:37.738Z" }, + { url = "https://files.pythonhosted.org/packages/e4/4f/f7e2dad6756b2fe1fe75dd90a628c3b45f249d39f948dd90cd2476325417/regex-2026.7.19-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cc26a66e212fa5d6c6170c3a40d99d888db3020c6fdab1523250d4341382e44", size = 804546, upload-time = "2026-07-19T00:19:40.229Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d7/01d31d5bdb09bc026fab77f59a371fdf8f9b292e4810546c56182ca70498/regex-2026.7.19-cp314-cp314t-win32.whl", hash = "sha256:2c4e61e2e1be56f63ec3cc618aa9e0de81ef6f43d177205451840022e24f5b78", size = 274526, upload-time = "2026-07-19T00:19:42.398Z" }, + { url = "https://files.pythonhosted.org/packages/52/0e/cea4ce73bc0a8247a0748228ae6669984c7e1f8134b6fa66e59c0572e0ea/regex-2026.7.19-cp314-cp314t-win_amd64.whl", hash = "sha256:c639ea314df70a7b2811e8020448c75af8c9445f5a60f8a4ced81c306a9380c2", size = 283763, upload-time = "2026-07-19T00:19:44.644Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b6/26e41975febae63b7a6e3e02f32cff6cff2e4f10d19c929082f56aebf7c6/regex-2026.7.19-cp314-cp314t-win_arm64.whl", hash = "sha256:9a15e785f244f3e07847b984ce8773fc3da10a9f3c131cc49a4c5b4d672b4547", size = 283451, upload-time = "2026-07-19T00:19:46.639Z" }, +] + +[[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 = "ruff" +version = "0.12.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/f0/e0965dd709b8cabe6356811c0ee8c096806bb57d20b5019eb4e48a117410/ruff-0.12.12.tar.gz", hash = "sha256:b86cd3415dbe31b3b46a71c598f4c4b2f550346d1ccf6326b347cc0c8fd063d6", size = 5359915, upload-time = "2025-09-04T16:50:18.273Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/79/8d3d687224d88367b51c7974cec1040c4b015772bfbeffac95face14c04a/ruff-0.12.12-py3-none-linux_armv6l.whl", hash = "sha256:de1c4b916d98ab289818e55ce481e2cacfaad7710b01d1f990c497edf217dafc", size = 12116602, upload-time = "2025-09-04T16:49:18.892Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c3/6e599657fe192462f94861a09aae935b869aea8a1da07f47d6eae471397c/ruff-0.12.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7acd6045e87fac75a0b0cdedacf9ab3e1ad9d929d149785903cff9bb69ad9727", size = 12868393, upload-time = "2025-09-04T16:49:23.043Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d2/9e3e40d399abc95336b1843f52fc0daaceb672d0e3c9290a28ff1a96f79d/ruff-0.12.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:abf4073688d7d6da16611f2f126be86523a8ec4343d15d276c614bda8ec44edb", size = 12036967, upload-time = "2025-09-04T16:49:26.04Z" }, + { url = "https://files.pythonhosted.org/packages/e9/03/6816b2ed08836be272e87107d905f0908be5b4a40c14bfc91043e76631b8/ruff-0.12.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:968e77094b1d7a576992ac078557d1439df678a34c6fe02fd979f973af167577", size = 12276038, upload-time = "2025-09-04T16:49:29.056Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d5/707b92a61310edf358a389477eabd8af68f375c0ef858194be97ca5b6069/ruff-0.12.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42a67d16e5b1ffc6d21c5f67851e0e769517fb57a8ebad1d0781b30888aa704e", size = 11901110, upload-time = "2025-09-04T16:49:32.07Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3d/f8b1038f4b9822e26ec3d5b49cf2bc313e3c1564cceb4c1a42820bf74853/ruff-0.12.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b216ec0a0674e4b1214dcc998a5088e54eaf39417327b19ffefba1c4a1e4971e", size = 13668352, upload-time = "2025-09-04T16:49:35.148Z" }, + { url = "https://files.pythonhosted.org/packages/98/0e/91421368ae6c4f3765dd41a150f760c5f725516028a6be30e58255e3c668/ruff-0.12.12-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:59f909c0fdd8f1dcdbfed0b9569b8bf428cf144bec87d9de298dcd4723f5bee8", size = 14638365, upload-time = "2025-09-04T16:49:38.892Z" }, + { url = "https://files.pythonhosted.org/packages/74/5d/88f3f06a142f58ecc8ecb0c2fe0b82343e2a2b04dcd098809f717cf74b6c/ruff-0.12.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ac93d87047e765336f0c18eacad51dad0c1c33c9df7484c40f98e1d773876f5", size = 14060812, upload-time = "2025-09-04T16:49:42.732Z" }, + { url = "https://files.pythonhosted.org/packages/13/fc/8962e7ddd2e81863d5c92400820f650b86f97ff919c59836fbc4c1a6d84c/ruff-0.12.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:01543c137fd3650d322922e8b14cc133b8ea734617c4891c5a9fccf4bfc9aa92", size = 13050208, upload-time = "2025-09-04T16:49:46.434Z" }, + { url = "https://files.pythonhosted.org/packages/53/06/8deb52d48a9a624fd37390555d9589e719eac568c020b27e96eed671f25f/ruff-0.12.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2afc2fa864197634e549d87fb1e7b6feb01df0a80fd510d6489e1ce8c0b1cc45", size = 13311444, upload-time = "2025-09-04T16:49:49.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/81/de5a29af7eb8f341f8140867ffb93f82e4fde7256dadee79016ac87c2716/ruff-0.12.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0c0945246f5ad776cb8925e36af2438e66188d2b57d9cf2eed2c382c58b371e5", size = 13279474, upload-time = "2025-09-04T16:49:53.465Z" }, + { url = "https://files.pythonhosted.org/packages/7f/14/d9577fdeaf791737ada1b4f5c6b59c21c3326f3f683229096cccd7674e0c/ruff-0.12.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a0fbafe8c58e37aae28b84a80ba1817f2ea552e9450156018a478bf1fa80f4e4", size = 12070204, upload-time = "2025-09-04T16:49:56.882Z" }, + { url = "https://files.pythonhosted.org/packages/77/04/a910078284b47fad54506dc0af13839c418ff704e341c176f64e1127e461/ruff-0.12.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b9c456fb2fc8e1282affa932c9e40f5ec31ec9cbb66751a316bd131273b57c23", size = 11880347, upload-time = "2025-09-04T16:49:59.729Z" }, + { url = "https://files.pythonhosted.org/packages/df/58/30185fcb0e89f05e7ea82e5817b47798f7fa7179863f9d9ba6fd4fe1b098/ruff-0.12.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5f12856123b0ad0147d90b3961f5c90e7427f9acd4b40050705499c98983f489", size = 12891844, upload-time = "2025-09-04T16:50:02.591Z" }, + { url = "https://files.pythonhosted.org/packages/21/9c/28a8dacce4855e6703dcb8cdf6c1705d0b23dd01d60150786cd55aa93b16/ruff-0.12.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:26a1b5a2bf7dd2c47e3b46d077cd9c0fc3b93e6c6cc9ed750bd312ae9dc302ee", size = 13360687, upload-time = "2025-09-04T16:50:05.8Z" }, + { url = "https://files.pythonhosted.org/packages/c8/fa/05b6428a008e60f79546c943e54068316f32ec8ab5c4f73e4563934fbdc7/ruff-0.12.12-py3-none-win32.whl", hash = "sha256:173be2bfc142af07a01e3a759aba6f7791aa47acf3604f610b1c36db888df7b1", size = 12052870, upload-time = "2025-09-04T16:50:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/85/60/d1e335417804df452589271818749d061b22772b87efda88354cf35cdb7a/ruff-0.12.12-py3-none-win_amd64.whl", hash = "sha256:e99620bf01884e5f38611934c09dd194eb665b0109104acae3ba6102b600fd0d", size = 13178016, upload-time = "2025-09-04T16:50:12.559Z" }, + { url = "https://files.pythonhosted.org/packages/28/7e/61c42657f6e4614a4258f1c3b0c5b93adc4d1f8575f5229d1906b483099b/ruff-0.12.12-py3-none-win_arm64.whl", hash = "sha256:2a8199cab4ce4d72d158319b63370abf60991495fb733db96cd923a34c52d093", size = 12256762, upload-time = "2025-09-04T16:50:15.737Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + +[[package]] +name = "ty" +version = "0.0.61" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/63/6944925d0fe9a4bb9cc744e6c045a42bbd2ee4654c103190674577a36c3f/ty-0.0.61.tar.gz", hash = "sha256:acbf0d914cc7e2e57ccc440036af36114819e2a604a5ffb554e72e4ca7dd65a2", size = 6234957, upload-time = "2026-07-18T01:39:54.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/cf/044f31523e2768e3e64b0ca2ec32f70b3a731d4a2caa6ea110baf26e251c/ty-0.0.61-py3-none-linux_armv6l.whl", hash = "sha256:148779b8675eac93f40ec58bd70037fe67537117f20a23272264f8f136d41336", size = 11891448, upload-time = "2026-07-18T01:39:18.449Z" }, + { url = "https://files.pythonhosted.org/packages/d2/55/558cfe76b65d91d1854bbfac336020bd42fd887caa632d845d13c0c539eb/ty-0.0.61-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:08217382b3385808ee7288501ea3214b32631b08d1fd091ece6799b0c95264c5", size = 11602442, upload-time = "2026-07-18T01:39:20.914Z" }, + { url = "https://files.pythonhosted.org/packages/27/be/78c0ae6634cd606a68e5b46b338db427a48a1800c96a749b2d2f7a702e03/ty-0.0.61-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d99c729011b47dec20e78a32ac9c8f6defd4cf62f7bb851bbccf70dde6cee50", size = 11125286, upload-time = "2026-07-18T01:39:22.893Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/a40793962f1b6337938ddb0bca7496b54e70879e23b4d2cc8dfd7e5d1af3/ty-0.0.61-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cda607978ae271b77e51c947663218bce635c3507e256865444b10c37cdb60d", size = 11663403, upload-time = "2026-07-18T01:39:25.017Z" }, + { url = "https://files.pythonhosted.org/packages/98/c1/7879244da5b30407dc368946d36be5024380073408b079f144ffe034030e/ty-0.0.61-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0d78f160a0f9434d570cdcdbc4dafba1f6aac3c47a32f9f63995b3cb55ffe4b6", size = 11715250, upload-time = "2026-07-18T01:39:27.045Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/8a4637cd58abd37f315dd515e24c582986cb1bfdf2edc4786882f5a4f69a/ty-0.0.61-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09aeab4800b36e93e4ce918699004da642d74988cac920b7592a6a2b9be6611c", size = 12393876, upload-time = "2026-07-18T01:39:29.197Z" }, + { url = "https://files.pythonhosted.org/packages/27/4b/27e7c640b1272743503229aa17ae2167a538040c4716a2fa1777c2b34fea/ty-0.0.61-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dccc8136df44142a109953a168be17b4915c99876b047d0b6672c31dae939bdf", size = 12958187, upload-time = "2026-07-18T01:39:31.308Z" }, + { url = "https://files.pythonhosted.org/packages/3a/f5/70eaaefb6081fb0a8115cff66fbfaa20dafac8c646df2477adad95a59de2/ty-0.0.61-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:220760c2d13a887d027ee1093172c24ac35b6e634805329c93a30908ae4d3f5c", size = 12560101, upload-time = "2026-07-18T01:39:33.35Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/17bae3b6429b5c479dc6c1e344d34e1f79efbc27531f15f3ee5b5da63745/ty-0.0.61-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:effefbb89da7128d18059529d1c2ea390fe7f1f3882690d257ca2143d49a0c34", size = 12225389, upload-time = "2026-07-18T01:39:35.436Z" }, + { url = "https://files.pythonhosted.org/packages/d2/0e/2ac380ba20d6395542c8df1d6fa4f00e2aead784c2e6aaefa1e02ed0610c/ty-0.0.61-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ba8b28a5ef811d5bb6461e37d76110c06fd20487474865c323d3d18b08b972b2", size = 12548403, upload-time = "2026-07-18T01:39:37.556Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e5/7da4b73e825e1a9808c26d68b0156e9a37aede1846191210dfffb8c64042/ty-0.0.61-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:88ecd6d9b05e8174b1860dac9bd3e188d6cef5702b0d3239fd9f94f6ac73a29d", size = 11621813, upload-time = "2026-07-18T01:39:39.919Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3c/5b58015e998cd0d89b17a463b6321421457d86d987574e8dac65ddfceba3/ty-0.0.61-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb0cdfe4c48542ffb9a1139825dfa3d4aae49e96e966682ef7da762ab97831ff", size = 11734101, upload-time = "2026-07-18T01:39:42.097Z" }, + { url = "https://files.pythonhosted.org/packages/a6/21/294f4cc819b7b12ed659fd860e5cdfbd592d4c768c8f23596685dbc43e6b/ty-0.0.61-py3-none-musllinux_1_2_i686.whl", hash = "sha256:dff03873c0c3d0b44738f8b6d403b0756a31cf54c65136397df7624c6159b1f0", size = 11988401, upload-time = "2026-07-18T01:39:44.183Z" }, + { url = "https://files.pythonhosted.org/packages/2e/26/0f96f79fdac118521a9771e9eef3f9b3f447d647b2c77953e80a1715c7e8/ty-0.0.61-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a9210e80e3d41c1dfc751e9e8e0980272f475031fafd0fb0f48aee233c78da03", size = 12330624, upload-time = "2026-07-18T01:39:46.662Z" }, + { url = "https://files.pythonhosted.org/packages/e3/08/1e62d1bca5c0cebdc7a34db1f4b61557aab85961cedd56953dd2c32d3e66/ty-0.0.61-py3-none-win32.whl", hash = "sha256:e3e1fe06f49a5492a922a5df2739834aa5ee978c7dd10414119dc8755cc40c9c", size = 11313991, upload-time = "2026-07-18T01:39:48.761Z" }, + { url = "https://files.pythonhosted.org/packages/26/f1/d8e33b3aeb36b73d81ae34d10e46ec4abf506d68f4e0a1491a76a593dd42/ty-0.0.61-py3-none-win_amd64.whl", hash = "sha256:25f2291169e0298fcdbba1b1fea64f8207a6c1908dddef32346fd5e3e6ac9221", size = 12311717, upload-time = "2026-07-18T01:39:50.881Z" }, + { url = "https://files.pythonhosted.org/packages/e1/14/7caec26d93a943c0e7d15eb7374644508d08cbd387d112b722b12d14e044/ty-0.0.61-py3-none-win_arm64.whl", hash = "sha256:3e496f7698bc4b5bbb1eb66d8b5799ba87596d88d36604ca359083893fa2fc49", size = 11693485, upload-time = "2026-07-18T01:39:52.73Z" }, +] + +[[package]] +name = "typer" +version = "0.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/78/fda3361b56efc27944f24225f6ecd13d96d6fcfe37bd0eb34e2f4c63f9fc/typer-0.27.0.tar.gz", hash = "sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5", size = 203430, upload-time = "2026-07-15T19:21:07.007Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl", hash = "sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1", size = 122716, upload-time = "2026-07-15T19:21:05.553Z" }, +] + +[[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.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[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" }, +] diff --git a/zensical.toml b/zensical.toml index ebff6b9..b6a4ac8 100644 --- a/zensical.toml +++ b/zensical.toml @@ -21,7 +21,17 @@ nav = [ ]} ]}, {"Documentation" = [ - "documentation/index.md" + "documentation/index.md", + {"Privacy Guard" = [ + {"Architecture" = [ + "documentation/privacy-guard/architecture/index.md", + {"Request lifecycle" = "documentation/privacy-guard/architecture/request-lifecycle.md"}, + {"Entity-processing engines" = "documentation/privacy-guard/architecture/entity-processing-engines.md"}, + {"Configuration and text boundary" = "documentation/privacy-guard/architecture/configuration.md"}, + {"Service boundary" = "documentation/privacy-guard/architecture/service-boundary.md"}, + {"Safety and limits" = "documentation/privacy-guard/architecture/safety-and-limits.md"} + ]} + ]} ]} ]