diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43625d7..f346c5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,8 @@ jobs: version: "0.11.19" enable-cache: true - run: uv sync --python 3.13 --extra tests --extra typing - - run: uvx ruff check src tests - - run: uv run pytest tests/core tests/logging tests/inputs - - run: uv run pytest tests/connectors/test_cloud_params.py tests/connectors/test_connectors.py tests/connectors/test_secrets.py tests/connectors/test_cli.py tests/connectors/test_mcp.py tests/connectors/meshy/test_models.py + - run: uv run --with pip-audit==2.10.0 pip-audit --skip-editable + - run: uvx ruff check src tests examples README.md docs/package-surface.md + - run: uv run mypy src/extended_data + - run: uv run pytest tests - run: uv build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a5312d8..63aaa4a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,5 +44,10 @@ jobs: with: version: "0.11.19" enable-cache: true + - run: uv sync --python 3.13 --extra tests --extra typing + - run: uv run --with pip-audit==2.10.0 pip-audit --skip-editable + - run: uvx ruff check src tests examples README.md docs/package-surface.md + - run: uv run mypy src/extended_data + - run: uv run pytest tests - run: uv build - run: uv publish --trusted-publishing always diff --git a/README.md b/README.md index bba6e12..60460e1 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,10 @@ # Extended Data Comprehensive Python data utilities for serialization, configuration inputs, -structured logging, vendor data connectors, and workflow-oriented integrations. +structured logging, external data connectors, and workflow-oriented integrations. -This repository is the clean major-version consolidation of the previous -`extended-data-library` Python packages. The old package namespaces are not -preserved; the public API now lives under `extended_data`. +The public API lives under one `extended_data` namespace with explicit tiers for +pure primitives, extended containers, and higher-order data processors. ## Install @@ -18,22 +17,47 @@ Optional integrations are installed by feature: ```bash pip install "extended-data[aws,github,vault]" pip install "extended-data[google,slack,zoom]" -pip install "extended-data[ai]" +pip install "extended-data[anthropic,cursor]" +pip install "extended-data[ai]" # LangChain, MCP, and Strands +pip install "extended-data[langchain,mcp,strands]" pip install "extended-data[meshy,mcp]" +pip install "extended-data[meshy,vector,webhooks]" pip install "extended-data[secrets]" ``` +Published runtime extras are `anthropic`, `aws`, `cursor`, `github`, `google`, +`langchain`, `mcp`, `meshy`, `secrets`, `slack`, `strands`, `vault`, `vector`, +`webhooks`, `zoom`, and aggregate `ai`. + +CrewAI adapters remain available when `crewai` is installed independently, but +`extended-data` intentionally does not publish a CrewAI extra while current +CrewAI releases pull vulnerable `chromadb` versions transitively. +The `vector` extra installs `sqlite-vec` for local vector search; embedding +model packages such as `sentence-transformers` are user-managed while current +releases pull vulnerable `torch` versions transitively. + ## Usage ```python -from extended_data import ConnectorFabric, InputProvider, Logging, decode_json, encode_yaml +from extended_data import ConnectorFabric, DataFile, DataWorkflow, ExtendedDict, InputProvider, Logging, decode_file +from extended_data.primitives import decode_json, encode_yaml, number_to_words, redact_sensitive_text logger = Logging(logger_name="example") inputs = InputProvider(inputs={"GITHUB_OWNER": "jbcom"}, from_environment=False) connectors = ConnectorFabric(inputs=inputs.inputs, logger=logger) data = decode_json('{"status": "ok"}') +payload = ExtendedDict(data).deep_merge({"source": "example"}) +decoded_file = decode_file('{"service": {"name": "api"}}', suffix="json") +artifact = DataFile.decode('{"service": {"name": "api"}}', suffix="json") +workflow = DataWorkflow.from_value(payload).merge({"region": "us-east-1"}).transform("unhump").result() -print(encode_yaml(data)) +print(encode_yaml(payload)) +print(decoded_file["service"]["name"].upper_first()) +print(number_to_words(42)) +print(redact_sensitive_text("Authorization: Bearer raw_token")) +print(redact_sensitive_text("failed for user@example.com", values=["user@example.com"])) +print(artifact.metadata["encoding"]) +print(workflow.as_builtin()) ``` The fabric can also instantiate any registered connector by name: @@ -46,26 +70,264 @@ github = connectors.get_connector( ) ``` +Built-in connector classes are also package-root exports when direct +construction reads better: + +```python +from extended_data import GitHubConnector, SlackConnector +``` + Connector names are normalized before lookup. If a known built-in connector is requested without its optional extra installed, the registry raises an `ImportError` with the matching `extended-data[...]` install target. +Inspect the connector catalog and runtime availability before wiring external +data workflows: + +```python +names = connectors.list_connectors() +available = connectors.list_available_connectors() +catalog = connectors.list_connector_info() +github_info = connectors.get_connector_info("github") +cloud_connectors = connectors.list_connectors_by_category("cloud") +repository_connectors = connectors.list_connectors_by_capability("repositories") +``` + +`list_connectors()` returns an `ExtendedList` of catalog connector names, +including known built-ins whose optional SDK extras are not installed yet. Use +`list_available_connectors()` when a workflow needs only connectors runnable in +the current environment. Use `list_connector_info()` when a workflow needs +availability, category, capability, extra, install, class, module, and +description metadata. Use +`list_connector_categories()`, `list_connector_capabilities()`, +`list_connectors_by_category()`, and `list_connectors_by_capability()` when a +workflow needs to select integrations by data domain instead of hard-coding a +single connector name. + +The installed CLI exposes the package's Tier 3 data boundary plus the connector +catalog and call surface: + +```bash +extended-data decode '{"service": {"name": "api"}}' --suffix json +extended-data decode --file config.yaml --output json +extended-data inspect --file config.yaml +extended-data merge config/base.yaml config/dev.yaml --output yaml +extended-data transform --file payload.json --step reconstruct --step unhump +extended-data list +extended-data list --category cloud +extended-data list --capability repositories --json +extended-data info github --json +extended-data methods github --json +``` + ## Package Shape ```text extended_data/ - core serialization, files, types, transforms + primitives/ Tier 1 pure functions and codecs + containers/ Tier 2 ExtendedString/Dict/List/Tuple/Set wrappers + io/ Tier 3 file, import, export, and base64 processors inputs/ InputProvider and decorator-based input injection logging/ structured lifecycle logging - connectors/ ConnectorFabric and vendor adapters - secrets/ Python access to secret sync primitives - workflows/ higher-order workflow composition + connectors/ Tier 3 ConnectorFabric and data integrations + secrets/ SecretSync CLI bridge and typed result exports + workflows/ Tier 3 higher-order workflow composition ``` -Vendor connectors are first-class adapters in the data fabric. `ConnectorFabric` +Tier 1 primitive names are explicit in this major version and live under +`extended_data.primitives`, not the package root. Use +`bytes_to_string()` for bytes-like coercion and `string_to_bool()`, +`string_to_int()`, `string_to_float()`, `string_to_path()`, +`string_to_date()`, `string_to_datetime()`, and `string_to_time()` for scalar +string conversion. Use `redact_sensitive_text()` and +`redact_sensitive_data()` for diagnostic and JSON-like payload redaction. Pass +`values=[...]` when a caller knows specific context values, such as resource +IDs, emails, paths, or URLs, must be withheld in addition to common secret +fields. The old `bytestostr` and `strto*` helper names are not preserved. Old +package import namespaces are not shimmed; missing imports are intentional so +incorrect imports fail fast. +Tier 1 public exports stay function-oriented; use `get_default_dict()` for +nested or sorted default mappings instead of importing the internal helper class. + +Connectors are first-class data integrations in the fabric. `ConnectorFabric` uses the registry to resolve connectors by name, injects shared input/logging context, caches connector instances, and lets specialized helpers coexist with -generic vendor lookup. +generic connector lookup. `list_connectors()` returns the full connector +catalog, including known connectors that need an `extended-data[...]` extra; use +`list_available_connectors()` for registered connectors whose runtime +requirements are installed. +Catalog entries include normalized categories and capabilities so workflows can +select cloud, AI, communications, development, media, or secrets integrations +without string matching class names. +Custom `ConnectorBase` subclasses can set `CONNECTOR_CATEGORY` and +`CONNECTOR_CAPABILITIES` to publish the same metadata through entry-point +registration. +Secret-like cache key fields such as `token`, `api_key`, `password`, and +`client_secret` are hashed before they are stored in the fabric cache. +`AWSConnector` and `GoogleConnector` are unified first-class classes: S3, +Organizations, SSO, Workspace, Cloud Resource Manager, Billing, and services +operations live on those connectors directly rather than on separate +`*Full` classes. +Google registry names are unified as well: use `google` for Workspace, Cloud, +Billing, and service discovery rather than split `google_*` connector aliases. +AWS Secrets Manager prefix loading is exposed as the generic +`load_secrets_by_prefix()` data method rather than as a service-specific helper. +AWS secret listing/deletion and Vault role filtering APIs use the canonical +`prefix` keyword; the old `name_prefix` convenience keyword is intentionally not +preserved. +Connector data payloads are promoted into Tier 2 containers at the boundary, so +decoded files, HTTP response data, GraphQL responses, and SDK-shaped maps can +use `ExtendedDict`, `ExtendedList`, and `ExtendedString` methods immediately. +Built-in HTTP connectors decode response bytes through the same file/data +decoding primitives instead of bypassing the boundary with transport-specific +JSON helpers. +Use `request_data_file()` when a connector workflow needs API response data and +non-secret provenance such as source URL, HTTP status, content type, method, +and endpoint in one `DataFile` artifact. Use `request_workflow()` when that +decoded response should immediately enter a `DataWorkflow` for merging, +transforming, writing, and provenance-preserving result handling; the +`get_workflow()`, `post_workflow()`, `put_workflow()`, `patch_workflow()`, and +`delete_workflow()` helpers provide verb-specific shortcuts. +Data-returning AI tool wrappers expose the same `ExtendedDict`/`ExtendedList` +payload contract; framework factory functions still return framework tool +objects. +The generic CLI `call` command and MCP bridge expose only methods that +advertise Extended Data payload returns. +The MCP bridge also exposes credential-free catalog tools such as +`extended_data_list_connector_info`, +`extended_data_list_connectors_by_category`, and +`extended_data_list_connectors_by_capability` so MCP clients can discover +usable integrations before invoking connector methods. +CLI `--arg` values that look like JSON are decoded through the same structured +data boundary used by files, inputs, and connector payloads before method +dispatch. +Google service-account strings and Meshy persisted manifests/metadata follow +that same decode path instead of parsing JSON in connector-local code. +AWS S3 JSON object writes and Meshy manifest writes use the shared export +boundary as well, keeping connector persistence aligned with Tier 3 file data; +Meshy vector-store metadata follows the same path. +CLI JSON output, MCP tool results, and SecretSync `results_json` use that same +export boundary after redaction. +GitHub workflow YAML generation and `Logging.exit_run()` stdout serialization +also route through the shared exporter. +Serialized CLI/MCP boundaries and connector API error messages reuse the Tier 1 +redaction primitives for common secret-bearing keys and token-shaped strings. +CLI and MCP connector calls pass method arguments through `values=[...]` as +context-sensitive diagnostic data, and connectors can add their own +operation-specific values for resource IDs, paths, URLs, emails, prompt text, or +external payload handles. Connector data methods can return structured +connector payloads without making stdout, tool responses, logs, or raised +transport errors a secret leak by default. Raw SDK/client objects and raw +transport responses remain available from the methods that explicitly return +them. + +The `secrets` connector integrates with the standalone SecretSync project +(`jbcom/secrets-sync`) through the `secretsync` CLI. It expects +`secretsync pipeline --output json` to return the stable pipeline result +envelope used by this package. +That JSON envelope is decoded through the same file/data primitives as other +structured connector payloads before being lowered into the `SyncResult` model. +Configuration inspection uses the same decoded file artifact path for YAML +pipeline configs. + +```python +from extended_data import SecretsConnector, SyncOptions + +result = SecretsConnector().run_pipeline( + "pipeline.yaml", + SyncOptions(dry_run=True), +) +``` + +The package is intentionally tiered: + +- Tier 1 functions stay stateless and composable. +- Tier 2 containers inherit `UserString`, `UserDict`, `UserList`, immutable + `tuple`, or `MutableSet`-compatible primitives and expose ergonomic methods + over Tier 1 functions. +- Tier 3 processors use the first two tiers to handle files, inputs, API data, + external integrations, and workflows. + +Tier 3 decoders return Tier 2 containers by default, so +data files, Base64 payloads, and directed inputs can immediately use +`ExtendedDict`, `ExtendedList`, `ExtendedTuple`, `ExtendedSet`, and +`ExtendedString` methods. +`ExtendedList.filter_values()` exposes the Tier 1 allowlist/denylist list +filtering primitive as a chainable container operation. +`ExtendedList.split_by_type()`, `ExtendedTuple.split_by_type()`, and +`ExtendedDict.split_by_type()` expose the Tier 1 type-splitting primitives as +type-name keyed `ExtendedDict` results. +`ExtendedList.first_non_empty()` and `ExtendedTuple.first_non_empty()` expose +ordered non-empty selection while preserving promoted nested values. +`ExtendedList.zipmap()` and `ExtendedTuple.zipmap()` compose ordered key +containers with value iterables and return promoted `ExtendedDict` mappings. +`ExtendedDict.first_non_empty_value()` returns the first matching non-empty +value as promoted Tier 2 data, so selected nested maps and lists remain +chainable. Use `ExtendedDict.first_non_empty_entry()` and +`ExtendedDict.non_empty_entries()` when callers need selected key/value entries +instead of just the selected value. +Generic type routing can still ask for plain data roles with +`typeof(value, primitive_only=True)`, which treats Extended containers as their +underlying `str`, `list`, `dict`, and `set` roles. +String tokenization stays inside the same surface: `ExtendedString.split()` +returns an `ExtendedList` of `ExtendedString` values, and partition operations +return `ExtendedTuple` values. `ExtendedString.is_partial_match()` and +`ExtendedString.is_non_empty_match()` expose the Tier 1 matching primitives +without requiring callers to drop back to function-only utility code. +`ExtendedString.to_bool()`, `to_int()`, `to_float()`, `to_path()`, +`to_date()`, `to_datetime()`, and `to_time()` expose the Tier 1 scalar +conversion family as direct string-container methods. +`ExtendedString.decode_json()`, `decode_yaml()`, `decode_toml()`, +`decode_hcl2()`, and `decode_base64()` expose structured text decoding from +the string container and promote decoded maps/lists into Tier 2 data by +default. +`ExtendedString.reconstruct_special_type()` and the container +`reconstruct_special_types()` methods restore booleans, numbers, dates, times, +paths, and structured JSON/YAML values while staying in promoted Tier 2 data. +Container `to_export_safe()` and `wrap_for_export()` methods expose the Tier 3 +export boundary directly from promoted values for JSON, YAML, TOML, HCL, and +raw string output. +Format encoders lower extended containers, including extended mapping keys, at +the serialization boundary. +`read_data_file()` is the direct file boundary for one-step read plus decode +workflows; it raises for missing files and promotes structured data into Tier 2 +containers by default. `DataFile` makes one decoded file or URL artifact +first-class with promoted data, promoted source metadata, detached +`as_extended()` views, direct write/export helpers, and a `workflow()` bridge +for artifact-first processing. DataFile source labels and metadata use the +shared Tier 1 redaction policy before they enter workflow steps or result +metadata. `DataWorkflow` makes multi-step compositions first-class: read, +decode, or accept a `DataFile` artifact, deep-merge mapping layers, apply named +transformations, write an output artifact, and keep the step trail in a +`WorkflowResult`. `DataWorkflow.merge_file()` reads a structured file through +the same `DataFile` boundary before merging it. Workflow metadata is promoted +and preserved across +transformations, lowering/promoting, and writes, so file and API provenance can +stay with the result. Completed workflow results expose detached promoted views +with `as_extended()` plus direct `to_export_safe()` and `wrap_for_export()` +helpers. `DataWorkflow.transform()` applies the same named Tier 2 transform +catalog exposed by the package CLI, including `reconstruct`, `unhump`, +`deduplicate`, `compact`, and string case transforms. Missing file inputs, +missing merge layers, unknown transform names, shape-incompatible transforms, +and empty writes fail loudly. +`InputProvider` stores its active, frozen, and merged input snapshots as +`ExtendedDict` values, so direct input-data access can use Tier 2 container +methods. `snapshot_inputs()` returns detached active or frozen snapshots, and +`replace_inputs()` installs a new active snapshot while clearing stale frozen +state by default. `get_input()` remains the scalar coercion boundary for +booleans, numbers, paths, datetimes, and credential strings; pass +`as_extended=True` when an injected raw or fallback input value should stay in +Tier 2 form and keep using container methods such as `reconstruct_special_types()` +and `to_export_safe()`. Stdin JSON plus JSON/YAML `decode_input()` paths use +the same file/data decoding boundary as structured files and connector +payloads. +`Logging` stores marked log message collections as `ExtendedDict` and +`ExtendedSet` values while keeping Python logger and handler objects plain. +Use `get_stored_messages()` or `snapshot_stored_messages()` when downstream +data workflows need detached promoted copies of collected messages. Runtime log +messages and attached JSON payloads use the same Tier 1 redaction policy as +connector diagnostics, and `exit_run()` formatting failures report redacted +result snapshots instead of raw payload data. More detail lives in [`docs/package-surface.md`](docs/package-surface.md). @@ -73,8 +335,10 @@ More detail lives in [`docs/package-surface.md`](docs/package-surface.md). ```bash uv sync --extra tests --extra typing +uv run --with pip-audit==2.10.0 pip-audit --skip-editable uv run pytest uv run ruff check src tests +uv run mypy src/extended_data uv build ``` diff --git a/docs/PUBLISHING_CHECKLIST.md b/docs/PUBLISHING_CHECKLIST.md new file mode 100644 index 0000000..fdd5203 --- /dev/null +++ b/docs/PUBLISHING_CHECKLIST.md @@ -0,0 +1,74 @@ +# Publishing Checklist + +`extended-data` releases are automated from `main` with release-please and +PyPI trusted publishing. Do not hand-edit versions, changelog entries, release +tags, or GitHub releases during the normal release path. + +## Release Model + +- `release-please` owns version detection, changelog updates, release PRs, and + Git tags. +- The package name is `extended-data`; PyPI publication uses the tighter + `extended-data` distribution name. +- The release workflow publishes only after release-please reports that a + release was created. +- The PyPI job uses OIDC trusted publishing through `uv publish`; no PyPI token + should be stored in repository secrets for the normal path. + +## Maintainer Preflight + +Run these before merging a release PR or manually dispatching release workflow +diagnostics: + +```bash +uv sync --extra tests --extra typing +uv run --with pip-audit==2.10.0 pip-audit --skip-editable +uv run ruff check . +uv run mypy src/extended_data +uv run pytest +uv build +``` + +## Workflow Hygiene + +- Keep `.github/workflows/*.yml` actions pinned to exact commit SHAs. +- Update adjacent version comments when refreshing action SHAs. +- Use `gh` to verify latest stable action releases before changing pins. +- Keep top-level `permissions: {}` and grant only job-scoped permissions. + +Current workflow action pins: + +| Action | Stable version | Commit SHA | +| --- | --- | --- | +| `actions/checkout` | `v6.0.3` | `df4cb1c069e1874edd31b4311f1884172cec0e10` | +| `actions/setup-python` | `v6.2.0` | `a309ff8b426b58ec0e2a45f0f869d46889d02405` | +| `astral-sh/setup-uv` | `v8.2.0` | `fac544c07dec837d0ccb6301d7b5580bf5edae39` | +| `googleapis/release-please-action` | `v5.0.0` | `45996ed1f6d02564a971a2fa1b5860e934307cf7` | + +## Publishing Flow + +1. Land normal feature, fix, docs, and maintenance commits using Conventional + Commit prefixes. +2. Let the release workflow open or update the release-please PR. +3. Review the release PR for the expected changelog and manifest updates. +4. Merge the release PR. +5. Confirm the release workflow created the GitHub release and published to + PyPI through trusted publishing. +6. Verify the package can be installed from PyPI: + +```bash +python -m pip install extended-data +python -c "import extended_data; print(extended_data.__version__)" +``` + +## Manual Repairs + +Manual tags or PyPI uploads are repair paths, not the release process. If a +release workflow fails after release-please creates a tag: + +1. Keep the failed tag intact while diagnosing unless the release is proven + unrecoverable. +2. Prefer rerunning the failed workflow job. +3. If a bad GitHub release was published, delete only the bad artifacts needed + for repair. +4. Document the repair in the PR or release notes. diff --git a/docs/package-surface.md b/docs/package-surface.md index cf2a6b8..97cd5a2 100644 --- a/docs/package-surface.md +++ b/docs/package-surface.md @@ -1,30 +1,294 @@ # Package Surface `extended-data` is one Python distribution with a single `extended_data` -namespace. The root package exposes the primitives users need most often: +namespace. The root package exposes first-class containers, Tier 3 processors, +and integrated connectors; pure Tier 1 utilities are imported from +`extended_data.primitives`. +The old `extended_data_types`, `lifecyclelogging`, +`directed_inputs_class`, and `vendor_connectors` import namespaces are not +preserved in this major version. ```python from extended_data import ( ConnectorFabric, + DataDecodeError, + DataFile, + DataWorkflow, + ExtendedDict, + ExtendedList, + ExtendedSet, + ExtendedString, + ExtendedTuple, + GitHubConnector, + GoogleConnector, InputProvider, Logging, + SecretsConnector, + SlackConnector, + SyncOptions, + list_data_transform_steps, + extend_data, + to_builtin, +) +from extended_data.primitives import ( decode_json, encode_yaml, - flatten_map, + normalize_data_encoding, + number_to_words, + redact_sensitive_text, +) +``` + +## Tiers + +- Tier 1 `extended_data.primitives` modules are pure functions and codecs for + strings, numbers, maps, lists, matching, state, redaction, type coercion, and + structured formats. +- Tier 2 `extended_data.containers` classes wrap Python container primitives as + `ExtendedString`, `ExtendedDict`, `ExtendedList`, `ExtendedTuple`, and + `ExtendedSet` with ergonomic methods over Tier 1 primitives. They use + `UserString`, `UserDict`, `UserList`, immutable `tuple`, or + `MutableSet`-compatible bases depending on the underlying data shape. +- Tier 3 processors use the first two tiers to handle files, imports, exports, + inputs, API data, external integrations, and workflows. + +Clean major-version primitive names, including JSON/YAML/TOML/HCL codecs, live +under `extended_data.primitives` and prefer explicit Python words over +inherited helper spellings: use `bytes_to_string()` and the `string_to_*()` +conversion family (`string_to_bool()`, `string_to_int()`, `string_to_float()`, +`string_to_path()`, `string_to_date()`, `string_to_datetime()`, and +`string_to_time()`). The old `bytestostr` and `strto*` helper names are +intentionally not preserved, and pure utility functions are not re-exported +from the package root. +Tier 1 public exports stay function-oriented; use `get_default_dict()` when a +workflow needs nested or sorted default mappings rather than importing the +internal sorted-default mapping helper class. +Use `redact_sensitive_text()` and `redact_sensitive_data()` when diagnostics or +JSON-like payloads need common secret-bearing keys and token-shaped strings +removed before display. Pass `values=[...]` when a caller knows additional +context-specific identifiers, such as emails, paths, URLs, or external resource +IDs, must be withheld as well; URL-encoded forms of those values are redacted +too. + +Direct JSON, YAML, TOML, and HCL primitive decode failures raise +`DataDecodeError` with format and position context while preserving the parser +exception as the cause; the public error message does not echo the raw payload. + +```python +name = ExtendedString("API Response Value").to_snake_case() +matched = ExtendedString("api-gateway").is_partial_match("gateway") +payload = ExtendedDict({"outer": {"inner": 1}}).flatten() +items = ExtendedList([1, [2, [3]]]).flatten() +services = ExtendedList(["api", "worker", "db"]).filter_values(allowlist=["api", "worker"]) +typed_items = ExtendedList(["api", 2, True]).split_by_type(primitive_only=True) +typed_aliases = ExtendedTuple(("api", 2, True)).split_by_type(primitive_only=True) +aliases = ExtendedTuple(("api", ("gateway",))).flatten() +tags = ExtendedSet({"prod", "prod", ""}).compact() +words = number_to_words(42) +encoding = normalize_data_encoding("YML") +safe_error = redact_sensitive_text( + "failed for user@example.com and user%40example.com", + values=["user@example.com"], +) +``` + +`ExtendedDict`, `ExtendedList`, `ExtendedTuple`, and `ExtendedSet` recursively +promote nested plain values on construction and mutation, so method chains can +continue through data loaded from normal Python literals: + +```python +payload = ExtendedDict({"service": {"name": "api"}}) +payload["service"]["name"].upper_first() +``` + +Mutation and common operator paths are part of that contract: `setdefault()`, +in-place dict merge, list in-place concatenation, list in-place repetition, +tuple slicing, tuple concatenation, and tuple repetition preserve Tier 2 +containers instead of leaking plain nested values. `ExtendedSet` named +mutators such as `update()`, `intersection_update()`, `difference_update()`, +and `symmetric_difference_update()` preserve promoted values as well. +String tokenization and partitioning paths are covered too: +`ExtendedString.split()`, `rsplit()`, and `splitlines()` return `ExtendedList` +values containing `ExtendedString` parts, while `partition()` and +`rpartition()` return `ExtendedTuple` values. String formatting paths +`format()` and `format_map()` return `ExtendedString`. String matching paths +`is_partial_match()` and `is_non_empty_match()` expose the Tier 1 matching +helpers through `ExtendedString`. Scalar conversion paths `to_bool()`, +`to_int()`, `to_float()`, `to_path()`, `to_date()`, `to_datetime()`, and +`to_time()` expose the Tier 1 `string_to_*()` family directly on +`ExtendedString`. Structured text paths `decode_json()`, `decode_yaml()`, +`decode_toml()`, `decode_hcl2()`, and `decode_base64()` decode from the string +container and promote decoded maps/lists into Tier 2 data by default. +`ExtendedString.reconstruct_special_type()` and container +`reconstruct_special_types()` methods restore booleans, numbers, paths, dates, +times, and structured JSON/YAML values while keeping reconstructed collections +inside Tier 2 containers. Container `to_export_safe()` and `wrap_for_export()` +methods expose the Tier 3 export boundary directly from promoted values for +JSON, YAML, TOML, HCL, and raw string output. + +Container methods that return derived collections stay in Tier 2 as well: +`ExtendedDict.filter()` returns an `ExtendedTuple` of accepted and rejected +`ExtendedDict` values, and `ExtendedDict.all_values()` returns an +`ExtendedList`. `ExtendedList.split_by_type()`, +`ExtendedTuple.split_by_type()`, and `ExtendedDict.split_by_type()` expose the +Tier 1 split helpers as type-name keyed `ExtendedDict` results; tuple inputs +keep tuple-shaped grouped values. `ExtendedList.first_non_empty()` and +`ExtendedTuple.first_non_empty()` return the first ordered non-empty value +without lowering promoted nested data. `ExtendedList.zipmap()` and +`ExtendedTuple.zipmap()` return promoted `ExtendedDict` mappings from ordered +key containers and value iterables. `ExtendedDict.first_non_empty_value()` +returns promoted Tier 2 values when it selects nested maps, lists, tuples, sets, +or strings. `ExtendedDict.first_non_empty_entry()` and +`ExtendedDict.non_empty_entries()` return promoted keyed entries for workflows +that need to preserve the selected key context. +Generic type routing can still ask for plain data roles: +`typeof(value, primitive_only=True)` reports Extended strings, lists, tuples, +mappings, and sets as `str`, `list`, `list`, `dict`, and `set`. + +Tier 3 file and decode surfaces promote decoded values into Tier 2 containers +by default: + +```python +from extended_data import DataFile, decode_file, read_data_file + +payload = decode_file('{"service": {"name": "api"}}', suffix="json") +file_payload = read_data_file("config/service.json") +artifact = DataFile.decode('{"service": {"name": "api"}}', suffix="json") +assert payload["service"]["name"].upper_first() == "Api" +assert file_payload["service"]["name"].upper_first() == "Api" +assert artifact.metadata["encoding"].upper_first() == "Json" +``` + +Pass `as_extended=False` when a decode boundary should return standard Python +containers. Use `extend_data(value)` to promote existing plain data and +`to_builtin(value)` to lower extended containers back to standard Python data. +Tuple values are promoted to `ExtendedTuple` and lowered back to Python tuples, +so the Tier 2 surface does not silently turn immutable input data into mutable +lists. +Format encoders lower Tier 2 containers the same way before serializing JSON, +YAML, TOML, and HCL output, including extended mapping keys that must become +plain strings before JSON handoff. + +`DataFile` is the Tier 3 artifact surface for one decoded file, URL, or +in-memory payload. It keeps `source`, `encoding`, and source metadata promoted, +returns decoded `data` as Tier 2 containers by default, exposes detached +`as_extended()` views, writes output artifacts through the same export boundary +as `write_file()`, and starts artifact-first processing with `workflow()`. +Source labels and metadata are redacted with the Tier 1 redaction policy before +they enter workflow step names or `WorkflowResult.metadata`; caller-supplied +metadata cannot override the sanitized core `source` and `path` fields. + +`DataWorkflow` is the Tier 3 composition surface for higher-order data +processing. It reads or decodes structured data through the file and format +processors, accepts `DataFile` artifacts with `from_data_file()`, promotes +values into Tier 2 containers by default, deep-merges in-memory or file-backed +mapping layers, applies reusable `WorkflowStep` functions or named transform +steps, writes output artifacts, and returns a `WorkflowResult` with the +completed value, output path, step trail, and promoted metadata. +`DataWorkflow.merge()` deep-merges mapping values through the Tier 2 +`ExtendedDict` primitive, and `merge_file()` decodes structured file layers +through `DataFile` before merging. `DataWorkflow.transform()` applies the same +named Tier 2 +transform catalog exposed by the CLI, including `reconstruct`, `unhump`, +`deduplicate`, `compact`, and string case transforms. Workflow metadata is +preserved across `then()`, `run()`, `merge()`, `merge_file()`, `transform()`, `as_builtin()`, +`as_extended()`, and `write()`, so file and API provenance from `DataFile` +artifacts remains attached to the result. `WorkflowResult.as_extended()` returns +a detached promoted view of the completed value, and result-level +`to_export_safe()` / `wrap_for_export()` expose the same export boundary used by +Tier 2 containers. + +```python +from extended_data import DataWorkflow + +env_data = DataWorkflow.from_file("config/dev.yaml").value +result = ( + DataWorkflow.from_file("config/base.yaml") + .merge(env_data, name="merge-env") + .transform("reconstruct", "unhump") + .write("build/config.yaml") +) + +assert result.steps == ( + "read:config/base.yaml", + "merge-env", + "transform:reconstruct", + "transform:unhump", + "write:build/config.yaml", ) +assert result.metadata["source"] == "config/base.yaml" +assert result.as_extended()["service"]["name"].upper_first() == "Api" +assert result.to_export_safe()["service"]["name"] == "api" +assert "unhump" in list_data_transform_steps() ``` -## Layers +Missing workflow input files raise `FileNotFoundError`, and empty workflow +writes raise `ValueError` unless `allow_empty=True` is passed. Missing merge +layers, unknown transform names, and operations that do not match the current +data shape raise instead of silently preserving stale workflow state. -- Core data primitives handle serialization, file decoding, type coercion, - string transforms, map/list transforms, and export-safe values. -- `InputProvider` loads input data from explicit mappings, environment - variables, and stdin, then decodes or coerces values through the same core - primitives. -- `Logging` provides structured lifecycle logging for applications and - connector workflows. -- `ConnectorFabric` caches and coordinates vendor connectors while sharing - input loading, logging, data normalization, retry behavior, and serialization. +`InputProvider` loads input data from explicit mappings, environment variables, +and stdin, then decodes or coerces values through the shared primitive and +file/data layers. Stdin JSON and JSON/YAML `decode_input()` paths use the same +structured decoder boundary as file and connector payloads. Its +`decode_input(..., as_extended=True)` path gives input-driven workflows the same +container bridge as file and Base64 decoding; fallback values use that same +promotion rule, so defaults do not silently drop back to plain dictionaries. +Requested input coercions are strict, and diagnostics identify the input key and +failed operation without echoing raw values from environment variables, stdin, +JSON, YAML, or Base64 payloads. Active, frozen, shifted, and merged input +snapshots are `ExtendedDict` values, and input decorator metadata/options are +promoted the same way. The old case-insensitive input mapping is intentionally +not preserved; exact keys keep configuration wiring explicit while still +letting direct snapshots use Tier 2 methods. Use `snapshot_inputs()` for a +detached promoted copy of active or frozen state, and `replace_inputs()` when a +workflow should install a new active snapshot instead of mutating `.inputs` +directly. + +```python +inputs = InputProvider(inputs={"service": {"name": "api"}}, from_environment=False) +assert inputs.inputs["service"]["name"].upper_first() == "Api" +assert isinstance(inputs.merge_inputs({"service": {"region": "us-east-1"}}), ExtendedDict) +assert inputs.snapshot_inputs()["service"]["region"].upper_first() == "Us-east-1" +fallback = inputs.decode_input("missing", default={"enabled": "true"}, as_extended=True) +assert fallback.reconstruct_special_types()["enabled"] is True +``` + +`get_input()` is the scalar coercion boundary for booleans, numbers, paths, +datetimes, and credential strings. Pass `as_extended=True` when a raw injected +input value should remain in Tier 2 form. + +`Logging` provides structured lifecycle logging for applications and connector +workflows without creating log files unless file output is explicitly enabled. +Stored log message collections are exposed as `ExtendedDict` values keyed by +storage marker, with each marker containing an `ExtendedSet` of promoted +messages. `get_stored_messages()` returns a detached promoted message set for +one marker, and `snapshot_stored_messages()` returns a detached `ExtendedDict` +copy of all stored collections for downstream export or workflow composition. +Runtime log messages and attached JSON payloads are redacted with the Tier 1 +redaction primitives before they reach Python logging handlers or stored message +collections. `exit_run()` formatting failures also report a redacted result +snapshot and suppress the internal formatting exception chain so diagnostics do +not echo raw payload data. + +`ConnectorFabric` caches and coordinates registered connectors while sharing input +loading, logging, data normalization, retry behavior, and serialization. +`AWSConnector` and `GoogleConnector` are unified connector classes in this +major version: common S3, Organizations, SSO, Workspace, Cloud Resource +Manager, Billing, and service-discovery operations live directly on those +connectors. The old split between base connector classes and separate `*Full` +connector classes is intentionally not preserved. +The Google registry surface is unified too: `google` is the first-class +connector name for Workspace, Cloud Resource Manager, Billing, and service +discovery operations. Split `google_cloud`, `google_workspace`, and +`google_billing` connector aliases are intentionally not preserved. +AWS Secrets Manager prefix loading is generic too: use +`AWSConnector.load_secrets_by_prefix()` when a workflow needs a promoted mapping +of secret names to values. The old service-specific ASM loader name is +intentionally not preserved. +AWS secret listing/deletion and Vault role filtering use the canonical `prefix` +keyword. The old `name_prefix` convenience keyword is intentionally not +preserved. ## Connector Fabric @@ -51,19 +315,200 @@ Both paths share the same input provider and lifecycle logger, and both cache instances by connector type and constructor inputs. Generic connector names are stripped and lowercased before lookup. +Every built-in connector class registered by name is also exported from +`extended_data` and `extended_data.connectors`. Those exports are real classes, +not `None` sentinels. Optional SDKs load when connector instances need them, so +package import remains lightweight while missing optional extras still fail at +the operation boundary with install guidance. `list_connectors()` reports the +complete connector catalog, including known connectors whose optional SDK extras +are not installed; use `list_available_connectors()` for only connectors whose +runtime requirements are installed. Use `list_connector_info()` when tooling +needs the complete catalog plus missing dependency and install guidance. +Catalog entries include normalized categories and capabilities; +`list_connector_categories()`, +`list_connector_capabilities()`, `list_connectors_by_category()`, and +`list_connectors_by_capability()` let workflows select integrations by data +domain or supported operation without parsing class names. `ConnectorFabric` +hashes secret-like cache-key fields such as `token`, `api_key`, `password`, and +`client_secret` before storing cache entries, so cache inspection and debug +output do not expose raw credential material. +Custom `ConnectorBase` subclasses can set `CONNECTOR_CATEGORY` and +`CONNECTOR_CAPABILITIES` so entry-point connectors participate in the same +catalog query surface. + +Connectors that inherit `ConnectorBase` can keep raw transport access with +`request()` or use `request_data()`, `get_data()`, `post_data()`, and the other +verb-specific helpers to decode HTTP JSON, YAML, TOML, HCL, or text responses +through the same Tier 2 container bridge used by file and input decoding. +Built-in connectors that parse HTTP JSON responses should decode response bytes +through these shared data primitives and lower to built-in values only at model +validation or redaction boundaries. Use +`request_data_file()` when an API workflow needs the decoded data plus +non-secret response provenance such as source URL, HTTP status, content type, +method, and endpoint in a `DataFile` artifact. Use `request_workflow()` when +that response should immediately become a `DataWorkflow` with the same promoted +metadata, named transforms, merge helpers, and export/write boundary. The +`get_workflow()`, `post_workflow()`, `put_workflow()`, `patch_workflow()`, and +`delete_workflow()` helpers mirror the decoded-data verb helpers for common API +workflows. +Connector methods that return external data payloads should call +`extend_result()` at the return boundary, making SDK-shaped dictionaries, +lists, decoded repository files, GraphQL results, and workflow-builder output +first-class `ExtendedDict`, `ExtendedList`, `ExtendedTuple`, and +`ExtendedString` values. This is an intentional major-version break from plain +`dict`/`list` payloads; use `to_builtin()` at serialization, CLI, MCP, or SDK +handoff boundaries. +Data-returning AI tool wrapper functions follow the same contract and annotate +their payload returns as `ExtendedDict` or `ExtendedList[ExtendedDict]`. +The generic CLI `call` command and MCP bridge expose only connector methods +that advertise Extended Data payload returns, so raw SDK client factories and +low-level HTTP helpers do not leak into serialized tool catalogs. +The MCP bridge also publishes credential-free catalog tools: +`extended_data_list_connectors`, `extended_data_list_available_connectors`, +`extended_data_list_connector_info`, `extended_data_get_connector_info`, +`extended_data_list_connector_categories`, `extended_data_list_connector_capabilities`, +`extended_data_list_connectors_by_category`, and +`extended_data_list_connectors_by_capability`. +CLI `--arg` values that look like JSON are decoded through the shared +structured data boundary before method dispatch, matching file, input, and +connector payload decoding. +Google service-account strings and Meshy persisted manifests/metadata use that +same boundary, so connector-local reads do not grow private JSON parsers. +AWS S3 JSON object writes and Meshy manifest writes go through the shared export +boundary, so connector persistence uses the same Tier 3 data-file encoding path; +Meshy vector-store metadata follows the same path. +Meshy logging helpers return `extended_data.logging.Logging` instances with a +Meshy storage marker instead of configuring global Python logging or importing a +connector-local logging stack at module import time. +CLI JSON output, MCP tool results, and SecretSync `results_json` are exported +through the same path after redaction. +GitHub workflow YAML generation and `Logging.exit_run()` stdout serialization +also route through the shared exporter. +Serialized CLI/MCP boundaries apply Tier 1 redaction after Tier 2 containers +are lowered to JSON-compatible data, and connector API error messages use the +same redaction policy before exceptions are raised. Common secret-bearing keys +such as `password`, `api_key`, `access_token`, `authorization`, and +`client_secret`, plus token-like strings in error text, are replaced with +`[REDACTED]` before CLI stdout/stderr, MCP tool responses, or raised transport +errors expose them. CLI and MCP connector calls pass method arguments through +`values=[...]` as context-specific diagnostic data, and connectors can add their +own operation-specific values for resource IDs, paths, URLs, emails, prompt +text, or external payload handles that are sensitive only in that operation. +LangChain, CrewAI, Strands, and auto-detection factory functions still return +plain framework tool object lists. + +```python +payload = github.get_repository_file("service.json") +assert payload["service"]["name"].upper_first() == "Api" +``` + +The `secrets` connector is the Python-facing bridge to the standalone SecretSync +project (`jbcom/secrets-sync`). It uses the `secretsync` CLI, which must emit +the stable `secretsync pipeline --output json` result envelope for both dry-run +and apply runs. The connector decodes that envelope through the shared file/data +primitives before lowering it into the `SyncResult` model. Configuration +inspection reads YAML configs through the same decoded `DataFile` artifact path. +Secrets tool factories are exported from `extended_data.secrets`; the duplicate +`extended_data.secrets.tools` module path is intentionally not preserved. + +```python +from extended_data import SecretsConnector, SyncOptions + +result = SecretsConnector().run_pipeline( + "pipeline.yaml", + SyncOptions(dry_run=True), +) +``` + +Use the catalog helpers when a workflow needs to inspect known integrations and +which ones can run in the current environment: + +```python +names = fabric.list_connectors() +available = fabric.list_available_connectors() +catalog = fabric.list_connector_info() +github_info = fabric.get_connector_info("github") +cloud_connectors = fabric.list_connectors_by_category("cloud") +repository_connectors = fabric.list_connectors_by_capability("repositories") +``` + +`list_connectors()` returns an `ExtendedList` of catalog connector names. +`list_available_connectors()` returns the subset runnable in the current +environment. Each catalog entry includes availability, source, category, +capabilities, extra name, install command, required packages, missing packages, +module, class, and description fields. +The installed CLI exposes the same discovery layer for shell automation: + +```bash +extended-data decode '{"service": {"name": "api"}}' --suffix json +extended-data decode --file config.yaml --output json +extended-data inspect --file config.yaml +extended-data merge config/base.yaml config/dev.yaml --output yaml +extended-data transform --file payload.json --step reconstruct --step unhump +extended-data list --json +extended-data list --category cloud +extended-data list --capability repositories --json +extended-data info github --json +extended-data methods github --json +``` + +The `extended-data` console script is the package-level CLI. Data commands use +`DataFile`, `DataWorkflow`, and the shared export boundary directly; connector +commands are delegated to the connector CLI so existing catalog, method, call, +and MCP workflows stay on the same entrypoint. + ## Optional Integrations -Install only the vendor or AI layers you need: +Install only the external service or AI layers you need: ```bash pip install "extended-data[aws,github,vault]" pip install "extended-data[google,slack,zoom]" -pip install "extended-data[ai]" +pip install "extended-data[anthropic,cursor]" +pip install "extended-data[ai]" # LangChain, MCP, and Strands +pip install "extended-data[langchain,mcp,strands]" pip install "extended-data[meshy,mcp]" +pip install "extended-data[meshy,vector,webhooks]" ``` +Published runtime extras: + +| Extra | Purpose | +| --- | --- | +| `extended-data[anthropic]` | Anthropic API connector and tools | +| `extended-data[aws]` | AWS connector operations | +| `extended-data[cursor]` | Cursor connector helpers | +| `extended-data[github]` | GitHub connector operations | +| `extended-data[google]` | Google Workspace, Cloud, Billing, and services | +| `extended-data[langchain]` | LangChain tool adapters | +| `extended-data[mcp]` | MCP server bridge | +| `extended-data[meshy]` | Meshy 3D asset connector | +| `extended-data[secrets]` | SecretSync Python bridge dependencies | +| `extended-data[slack]` | Slack connector operations | +| `extended-data[strands]` | Strands tool adapters | +| `extended-data[vault]` | Vault connector operations | +| `extended-data[vector]` | SQLite vector search for generated asset metadata | +| `extended-data[webhooks]` | Webhook listener support | +| `extended-data[zoom]` | Zoom connector operations | +| `extended-data[ai]` | Aggregate LangChain, MCP, and Strands install target | + +CrewAI tool adapters are still importable when users install `crewai` directly, +but `extended-data` does not expose a CrewAI extra while current CrewAI +dependency trees pull vulnerable `chromadb` releases. +The `vector` extra installs `sqlite-vec` for local vector search; embedding +model packages such as `sentence-transformers` remain user-managed while +current releases pull vulnerable `torch` versions. +All built-in CrewAI tool adapters use +`extended_data.connectors._optional.get_crewai_tool_decorator()` so missing or +incompatible CrewAI installs fail with the same user-managed install guidance. + Optional dependency checks live in `extended_data.connectors._optional`; there -are no old package compatibility shims in the public API. When a known built-in -connector is requested without its optional extra installed, the registry raises -an `ImportError` with the exact `extended-data[...]` install target instead of -reporting the connector as unknown. +are no old package compatibility shims in the public API. Missing old imports +are intentional in this major version so incorrect callers fail loudly. When a +known built-in connector is requested without its optional extra +installed, the registry raises an `ImportError` with the exact +`extended-data[...]` install target instead of reporting the connector as +unknown. Built-in connectors must also be registered through the +`extended_data.connectors` entry point group; missing entry-point registration is +treated as a package configuration error instead of being patched over by direct +source imports. diff --git a/examples/connectors/README.md b/examples/connectors/README.md index e55af93..17f1551 100644 --- a/examples/connectors/README.md +++ b/examples/connectors/README.md @@ -1,6 +1,15 @@ -# Examples +# Connector Examples -This directory contains working examples demonstrating how to use extended-data. +This directory contains working examples for `extended_data.connectors` and the +registered integrations that hang off `ConnectorFabric`. + +Connector examples assume the major-version `extended-data` contract: external +data payloads are promoted into Tier 2 containers at connector boundaries. +Callers can use `ExtendedDict`, `ExtendedList`, and `ExtendedString` methods on +decoded API, file, and SDK-shaped results, then call `to_builtin()` only when a +plain Python payload is needed for serialization or SDK handoff. +The direct AI-tool functions follow that same payload contract; only the +framework factory helpers return plain framework tool objects. ## Quick Start @@ -8,17 +17,23 @@ Install extended-data with the extras you need: ```bash # Install with all connectors -pip install extended-data[all] +pip install "extended-data[all]" # Or install specific connectors -pip install extended-data[aws,google,meshy] +pip install "extended-data[aws,google,meshy]" # For AI framework integration -pip install extended-data[langchain] -pip install extended-data[crewai] +pip install "extended-data[langchain]" + +# CrewAI adapters require a user-managed CrewAI install. extended-data does not +# currently publish a CrewAI extra because current CrewAI releases pull +# vulnerable chromadb versions transitively. # For the Meshy MCP server -pip install extended-data[meshy,mcp] +pip install "extended-data[meshy,mcp]" + +# For SecretSync pipeline inspection and dry-run syncs +pip install "extended-data[secrets]" ``` ## Examples @@ -28,6 +43,7 @@ pip install extended-data[meshy,mcp] - [`basic_aws.py`](basic_aws.py) - AWS connector with Organizations and S3 - [`basic_google.py`](basic_google.py) - Google Cloud connector with Workspace and Billing - [`basic_meshy.py`](basic_meshy.py) - Meshy AI 3D generation +- [`basic_secrets.py`](basic_secrets.py) - SecretSync pipeline config inspection and dry-run execution ### AI Agent Integration @@ -49,6 +65,10 @@ export GOOGLE_SERVICE_ACCOUNT='{"type": "service_account", ...}' # Meshy AI export MESHY_API_KEY="msy_your_key" +# SecretSync +export VAULT_ADDR="https://vault.example.com" +export AWS_REGION="us-east-1" + # For LangChain examples export ANTHROPIC_API_KEY="sk-ant-..." ``` @@ -57,8 +77,11 @@ export ANTHROPIC_API_KEY="sk-ant-..." ```bash # Run any example -python examples/basic_meshy.py +uv run python examples/connectors/basic_meshy.py # Run with debug logging -LOGLEVEL=DEBUG python examples/basic_meshy.py +LOGLEVEL=DEBUG uv run python examples/connectors/basic_meshy.py + +# Run the SecretSync bridge against a pipeline config +uv run python examples/connectors/basic_secrets.py pipeline.yaml ``` diff --git a/examples/connectors/basic_aws.py b/examples/connectors/basic_aws.py index 4880e85..0f04b11 100644 --- a/examples/connectors/basic_aws.py +++ b/examples/connectors/basic_aws.py @@ -27,27 +27,22 @@ def main() -> int: return 1 try: - from extended_data.connectors import AWSConnector, AWSConnectorFull + from extended_data.connectors import AWSConnector except ImportError: print("Error: Could not import extended_data.connectors. Install with: pip install extended-data[aws]") return 1 - # Basic connector - just session management - print("Creating basic AWS connector...") - AWSConnector() - print("Basic connector created successfully.") - - # Full connector with all operations - print("\nCreating full AWS connector...") - full_connector = AWSConnectorFull() - print("Full connector created successfully.") + print("Creating AWS connector...") + connector = AWSConnector() + print("AWS connector created successfully.") # List S3 buckets print("\n--- S3 Buckets ---") try: - buckets = full_connector.list_buckets() - for bucket in buckets[:5]: # Show first 5 - print(f" Bucket: {bucket}") + buckets = connector.list_s3_buckets() + for bucket_name, bucket in list(buckets.items())[:5]: # Show first 5 + created = bucket.get("creation_date") or bucket.get("CreationDate") + print(f" Bucket: {bucket_name} ({created})") if len(buckets) > 5: print(f" ... and {len(buckets) - 5} more buckets") except Exception as e: @@ -56,9 +51,10 @@ def main() -> int: # List organization accounts (if using Organizations) print("\n--- Organization Accounts ---") try: - accounts = full_connector.get_accounts() - for account in accounts[:5]: - print(f" Account: {account}") + accounts = connector.get_accounts() + for account_id, account in list(accounts.items())[:5]: + name = account.get("name") or account.get("Name") or account_id + print(f" Account: {account_id} ({name})") if len(accounts) > 5: print(f" ... and {len(accounts) - 5} more accounts") except Exception as e: diff --git a/examples/connectors/basic_google.py b/examples/connectors/basic_google.py index 3bc9cc0..cb78291 100644 --- a/examples/connectors/basic_google.py +++ b/examples/connectors/basic_google.py @@ -26,25 +26,19 @@ def main() -> int: return 1 try: - from extended_data.connectors import GoogleConnector, GoogleConnectorFull + from extended_data.connectors import GoogleConnector except ImportError: print("Error: Could not import extended_data.connectors. Install with: pip install extended-data[google]") return 1 - # Basic connector - print("Creating basic Google connector...") - GoogleConnector() - print("Basic connector created successfully.") - - # Full connector with all operations - print("\nCreating full Google connector...") - full_connector = GoogleConnectorFull() - print("Full connector created successfully.") + print("Creating Google connector...") + connector = GoogleConnector() + print("Google connector created successfully.") # List projects print("\n--- Google Cloud Projects ---") try: - projects = full_connector.list_projects() + projects = connector.list_projects() for project in projects[:5]: print(f" Project: {project}") if len(projects) > 5: @@ -56,7 +50,7 @@ def main() -> int: if os.getenv("GOOGLE_DOMAIN"): print("\n--- Workspace Users ---") try: - users = full_connector.list_users() + users = connector.list_users() for user in users[:5]: email = user.get("primaryEmail", "Unknown") print(f" User: {email}") diff --git a/examples/connectors/basic_secrets.py b/examples/connectors/basic_secrets.py new file mode 100644 index 0000000..863987e --- /dev/null +++ b/examples/connectors/basic_secrets.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Example: SecretSync connector usage. + +This example demonstrates the `extended-data[secrets]` bridge to the +standalone `jbcom/secrets-sync` CLI. + +Requirements: + pip install extended-data[secrets] + secretsync must be installed on PATH + +Run: + uv run python examples/connectors/basic_secrets.py pipeline.yaml +""" + +from __future__ import annotations + +import sys + +from pathlib import Path + + +def main() -> int: + """Inspect a SecretSync pipeline config and run a dry-run through the CLI contract.""" + try: + from extended_data import OutputFormat, SecretsConnector, SyncOptions + except ImportError: + print("Error: Could not import SecretSync support. Install with: pip install extended-data[secrets]") + return 1 + + config_path = Path(sys.argv[1] if len(sys.argv) > 1 else "pipeline.yaml") + connector = SecretsConnector() + + print(f"Inspecting SecretSync config: {config_path}") + config_info = connector.get_config_info(str(config_path)) + if not config_info["valid"]: + print(f"Error: {config_info['error_message']}") + return 1 + + print( + "Config summary: " + f"{config_info['source_count']} source(s), " + f"{config_info['target_count']} target(s), " + f"merge store={config_info['has_merge_store']}", + ) + + if not connector.cli_available: + print("Error: secretsync CLI not available on PATH.") + print("Install jbcom/secrets-sync and re-run this example to exercise the dry-run contract.") + return 1 + + result = connector.run_pipeline( + str(config_path), + SyncOptions(dry_run=True, compute_diff=True, output_format=OutputFormat.JSON), + ) + + if not result["success"]: + print("Error: secretsync dry run failed.") + print("Run secretsync directly in a secure terminal for full diagnostics.") + print("The CLI must emit the stable `secretsync pipeline --output json` result envelope.") + return 1 + + print("Dry run completed successfully.") + if result["diff_output"]: + print("Diff output was returned by secretsync and is not printed because it may contain secret values.") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/connectors/langchain_tools.py b/examples/connectors/langchain_tools.py index e22378d..3111172 100644 --- a/examples/connectors/langchain_tools.py +++ b/examples/connectors/langchain_tools.py @@ -6,7 +6,7 @@ Requirements: pip install extended-data[meshy,langchain] - pip install langchain-anthropic # For Claude as the LLM + pip install langchain-anthropic langgraph # For Claude as the LLM and agent loop Environment Variables: MESHY_API_KEY: Your Meshy API key @@ -39,7 +39,7 @@ def main() -> int: from extended_data.connectors.meshy.tools import get_tools except ImportError: print("Error: Could not import required packages.") - print("Install with: pip install extended-data[meshy,langchain] langchain-anthropic") + print("Install with: pip install extended-data[meshy,langchain] langchain-anthropic langgraph") return 1 # Get Meshy tools for LangChain diff --git a/examples/connectors/mcp_server.py b/examples/connectors/mcp_server.py index eff1495..d188098 100644 --- a/examples/connectors/mcp_server.py +++ b/examples/connectors/mcp_server.py @@ -13,7 +13,7 @@ Usage: # Run the server (connects via stdio) - python examples/mcp_server.py + python examples/connectors/mcp_server.py # Or use the installed command meshy-mcp diff --git a/examples/core/README.md b/examples/core/README.md index 1fbb5fa..525e670 100644 --- a/examples/core/README.md +++ b/examples/core/README.md @@ -1,36 +1,39 @@ -# Extended Data Types Examples +# Core Examples -This directory contains working code samples demonstrating the capabilities of -the `Extended Data core` library. The examples intentionally mirror the public -guides and are part of the documented contract, not throwaway snippets. +This directory contains working code samples for the core `extended-data` +package surface: Tier 1 primitives, Tier 2 containers, and Tier 3 file/data +processors. The examples intentionally mirror the public README and package +surface docs, so treat them as part of the documented contract. ## Examples ### Basic Usage -- [`basic_usage.py`](basic_usage.py) - Common operations with strings, lists, and maps -- [`composed_workflows.py`](composed_workflows.py) - Layered config, Terraform-style HCL, YAML-native tags, and payload pipelines +- [`basic_usage.py`](basic_usage.py) - Common state helpers plus first-class `ExtendedString`, `ExtendedList`, and `ExtendedDict` operations +- [`composed_workflows.py`](composed_workflows.py) - Layered config, named workflow transforms, Terraform-style HCL, YAML-native tags, and payload pipelines - [`serialization.py`](serialization.py) - YAML, JSON, TOML, HCL, and Base64 encoding/decoding - [`file_operations.py`](file_operations.py) - File path utilities and Git repository helpers - [`string_transformations.py`](string_transformations.py) - Case conversion and string manipulation ## Related Documentation -- [Package docs](https://extended-data.dev/core/data-types/) -- [Getting started](https://extended-data.dev/getting-started/) -- [Packages overview](https://extended-data.dev/packages/) +- [Package surface](../../docs/package-surface.md) +- [Repository README](../../README.md) ## Running Examples ```bash -# From the repository root, run the full example suite -tox -e edt-examples +# From the repository root, install the local package +uv sync --extra tests --extra typing -# Or run a single example with the prepared tox environment +# Run a single example uv run python examples/core/basic_usage.py + +# Run the core test suite +uv run pytest tests/core ``` ## Requirements - Python 3.10-3.14 -- Extended Data core package +- `extended-data` diff --git a/examples/core/basic_usage.py b/examples/core/basic_usage.py index 99504a5..0772017 100644 --- a/examples/core/basic_usage.py +++ b/examples/core/basic_usage.py @@ -4,19 +4,15 @@ from __future__ import annotations from extended_data import ( + ExtendedDict, + ExtendedList, + ExtendedString, +) +from extended_data.primitives import ( all_non_empty, any_non_empty, - deep_merge, - filter_list, - filter_map, first_non_empty, - flatten_list, - flatten_map, is_nothing, - removeprefix, - removesuffix, - sanitize_key, - truncate, ) @@ -33,26 +29,26 @@ def demonstrate_state_utilities() -> None: def demonstrate_list_utilities() -> None: """Demonstrate list flattening and allowlist/denylist filtering.""" print("\n=== List Utilities ===") - nested = ["api", ["worker", ["scheduler"]], "docs"] - print("Flattened:", flatten_list(nested)) + nested = ExtendedList(["api", ["worker", ["scheduler"]], "docs"]) + print("Flattened:", nested.flatten()) - items = ["apple", "banana", "apricot", "cherry"] - print("Allowlist:", filter_list(items, allowlist=["apple", "apricot"])) - print("Denylist:", filter_list(items, denylist=["banana"])) + items = ExtendedList(["apple", "banana", "apricot", "cherry"]) + print("Allowlist:", items.filter_values(allowlist=["apple", "apricot"])) + print("Denylist:", items.filter_values(denylist=["banana"])) def demonstrate_map_utilities() -> None: """Demonstrate map merge, flatten, and filtering helpers.""" print("\n=== Map Utilities ===") - base = {"service": {"debug": False, "host": "localhost"}} + base = ExtendedDict({"service": {"debug": False, "host": "localhost"}}) override = {"service": {"debug": True, "port": 8080}} - print("Deep merge:", deep_merge(base, override)) + print("Deep merge:", base.deep_merge(override)) - nested = {"service": {"http": {"port": 8080}}, "enabled": True} - print("Flattened:", flatten_map(nested)) + nested = ExtendedDict({"service": {"http": {"port": 8080}}, "enabled": True}) + print("Flattened:", nested.flatten()) - payload = {"name": "api", "age": 30, "city": "Chicago", "active": True} - kept, removed = filter_map(payload, allowlist=["name", "city"]) + payload = ExtendedDict({"name": "api", "age": 30, "city": "Chicago", "active": True}) + kept, removed = payload.filter(allowlist=["name", "city"]) print("Filtered map:", kept) print("Removed map:", removed) @@ -60,11 +56,11 @@ def demonstrate_map_utilities() -> None: def demonstrate_string_utilities() -> None: """Demonstrate basic string cleanup helpers.""" print("\n=== String Utilities ===") - text = "prefix_content_suffix" - print("Remove prefix:", removeprefix(text, "prefix_")) - print("Remove suffix:", removesuffix(text, "_suffix")) - print("Truncate:", truncate("This value is intentionally too long", 20)) - print("Sanitize key:", sanitize_key("User Name (Primary)")) + text = ExtendedString("prefix_content_suffix") + print("Remove prefix:", text.remove_prefix("prefix_")) + print("Remove suffix:", text.remove_suffix("_suffix")) + print("Truncate:", ExtendedString("This value is intentionally too long").truncate(20)) + print("Sanitize key:", ExtendedString("User Name (Primary)").sanitize()) if __name__ == "__main__": diff --git a/examples/core/composed_workflows.py b/examples/core/composed_workflows.py index ff19efd..c63925a 100644 --- a/examples/core/composed_workflows.py +++ b/examples/core/composed_workflows.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """End-to-end workflow examples for Extended Data core. -This script demonstrates how the library's smaller helpers compose into more -complete configuration and payload pipelines. +This script demonstrates how package primitives, containers, and processors +compose into complete configuration and payload pipelines. """ from __future__ import annotations @@ -11,19 +11,17 @@ from tempfile import TemporaryDirectory from extended_data import ( + DataWorkflow, + ExtendedList, base64_decode, base64_encode, - decode_file, - decode_hcl2, - deduplicate_map, - deep_merge, - encode_hcl2, - filter_list, + list_data_transform_steps, + read_data_file, read_file, - to_snake_case, write_file, ) -from extended_data.yaml_utils import YamlTagged +from extended_data.primitives import decode_hcl2, encode_hcl2 +from extended_data.primitives.formats.yaml import YamlTagged def demonstrate_layered_config_workflow() -> None: @@ -46,17 +44,17 @@ def demonstrate_layered_config_workflow() -> None: write_file("config/base.yaml", base_config, tld=tld) write_file("config/dev.yaml", env_config, tld=tld) - base_text = read_file("config/base.yaml", tld=tld) - env_text = read_file("config/dev.yaml", tld=tld) - - base_data = decode_file(base_text, file_path="config/base.yaml") - env_data = decode_file(env_text, file_path="config/dev.yaml") - merged = deep_merge(base_data, env_data) - - write_file("build/config.yaml", merged, tld=tld) + env_data = DataWorkflow.from_file("config/dev.yaml", tld=tld).value + result = ( + DataWorkflow.from_file("config/base.yaml", tld=tld) + .merge(env_data, name="merge-env") + .write("build/config.yaml", tld=tld) + ) + result.to_export_safe() merged_text = read_file("build/config.yaml", tld=tld) print(merged_text) + print(f"Steps: {', '.join(result.steps)}") def demonstrate_terraform_handoff_workflow() -> None: @@ -92,12 +90,21 @@ def demonstrate_api_payload_workflow() -> None: print("\n=== API Payload Workflow ===\n") payload = { - "HTTPResponseCode": 200, - "SelectedServices": filter_list(["api", "worker", "db"], denylist=["db"]), + "HTTPResponseCode": "200", + "SelectedServices": ["api", "worker", "db", "api"], "Tags": ["api", "api", "docs"], + "EmptyValue": "", } - normalized = {to_snake_case(key): value for key, value in deduplicate_map(payload).items()} + def select_services(data): + return data | {"SelectedServices": ExtendedList(data["SelectedServices"]).filter_values(denylist=["db"])} + + workflow = ( + DataWorkflow.from_value(payload) + .then(("select-services", select_services)) + .transform("reconstruct", "deduplicate", "compact", "unhump") + ) + normalized = workflow.result().value with TemporaryDirectory() as tmpdir: tld = Path(tmpdir) @@ -105,6 +112,8 @@ def demonstrate_api_payload_workflow() -> None: payload_text = read_file("build/payload.json", tld=tld) print(payload_text) + print(f"Steps: {', '.join(workflow.steps)}") + print(f"Known transforms: {', '.join(list_data_transform_steps())}") def demonstrate_yaml_native_workflow() -> None: @@ -120,7 +129,7 @@ def demonstrate_yaml_native_workflow() -> None: tld = Path(tmpdir) write_file("template.yaml", template, tld=tld) rendered = read_file("template.yaml", tld=tld) - decoded = decode_file(rendered, file_path="template.yaml") + decoded = read_data_file("template.yaml", tld=tld) print(rendered) print(f"\nDecoded tag: {decoded['bucket_name'].tag}") diff --git a/examples/core/file_operations.py b/examples/core/file_operations.py index 7783f0e..a8c6c63 100755 --- a/examples/core/file_operations.py +++ b/examples/core/file_operations.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 -"""File operation examples for Extended Data core library. +"""File operation examples for the Extended Data core package. This module demonstrates file path utilities, encoding detection, -and file read/write operations provided by the library. +and file read/write operations provided by the package. """ from __future__ import annotations @@ -13,9 +13,9 @@ from extended_data import ( FilePath, - decode_file, file_path_depth, is_url, + read_data_file, read_file, resolve_local_path, write_file, @@ -69,7 +69,7 @@ def demonstrate_file_operations() -> None: with tempfile.TemporaryDirectory() as tmpdir: # Write a file test_file = Path(tmpdir) / "test.txt" - content = "Hello, Extended Data Types!\nThis is a test file." + content = "Hello, extended-data!\nThis is a test file." write_file(test_file, content) print(f"Wrote file: {test_file}") @@ -89,17 +89,16 @@ def demonstrate_file_operations() -> None: """ write_file(yaml_file, yaml_content) - yaml_text = read_file(yaml_file) - data = decode_file(yaml_text, file_path=yaml_file) + data = read_data_file(yaml_file) print(f"\nDecoded YAML file: {data}") + print(f"YAML service keys: {data.flatten().keys()}") # Write and read JSON json_file = Path(tmpdir) / "data.json" json_content = '{"users": [{"id": 1, "name": "Alice"}]}' write_file(json_file, json_content) - json_text = read_file(json_file) - data = decode_file(json_text, file_path=json_file) + data = read_data_file(json_file) print(f"Decoded JSON file: {data}") diff --git a/examples/core/serialization.py b/examples/core/serialization.py index e8f6d85..44f338b 100755 --- a/examples/core/serialization.py +++ b/examples/core/serialization.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 -"""Serialization examples for Extended Data core library. +"""Serialization examples for the Extended Data core package. This module demonstrates YAML, JSON, TOML, HCL, and Base64 encoding/decoding -utilities provided by the library. +utilities provided by the package. """ from __future__ import annotations @@ -10,6 +10,8 @@ from extended_data import ( base64_decode, base64_encode, +) +from extended_data.primitives import ( decode_hcl2, decode_json, decode_toml, diff --git a/examples/core/string_transformations.py b/examples/core/string_transformations.py index 9e5e629..82c54aa 100755 --- a/examples/core/string_transformations.py +++ b/examples/core/string_transformations.py @@ -1,13 +1,13 @@ #!/usr/bin/env python3 -"""String transformation examples for Extended Data core library. +"""String transformation examples for the Extended Data core package. This module demonstrates case conversion, humanization, pluralization, -and other string manipulation utilities provided by the library. +and other string manipulation utilities provided by the package. """ from __future__ import annotations -from extended_data import ( +from extended_data.primitives import ( humanize, ordinalize, pluralize, diff --git a/examples/inputs/README.md b/examples/inputs/README.md index 3acafce..7089fcb 100644 --- a/examples/inputs/README.md +++ b/examples/inputs/README.md @@ -1,19 +1,20 @@ -# Examples +# Input Examples -This directory contains working examples demonstrating the features of `Extended Data inputs`. +This directory contains working examples for `InputProvider` and the decorator +helpers in `extended_data.inputs`. ## Running Examples All examples can be run as Python modules from the project root: ```bash -# Install the package first -uv sync +# Install the local package first +uv sync --extra tests # Run examples -uv run python -m examples.basic_usage -uv run python -m examples.decorator_api -uv run python -m examples.encoding_decoding +uv run python examples/inputs/basic_usage.py +uv run python examples/inputs/decorator_api.py +uv run python examples/inputs/encoding_decoding.py ``` ## Available Examples @@ -23,6 +24,8 @@ uv run python -m examples.encoding_decoding Demonstrates the `InputProvider` API: - Loading inputs from environment variables - Environment variable prefix filtering +- Direct `ExtendedDict`/`ExtendedString` input snapshot access with `snapshot_inputs()` +- Active input replacement with `replace_inputs()` - Type conversion (boolean, integer, float) - Default values - Input freezing and thawing @@ -45,4 +48,5 @@ Demonstrates input decoding capabilities: - YAML decoding - Base64 decoding - Combined Base64 + JSON/YAML decoding -- Default values for missing inputs +- Tier 2 reconstruction/export methods on decoded inputs +- Promoted default values for missing inputs diff --git a/examples/inputs/basic_usage.py b/examples/inputs/basic_usage.py index a5507f7..d292f48 100644 --- a/examples/inputs/basic_usage.py +++ b/examples/inputs/basic_usage.py @@ -5,10 +5,12 @@ - Loading inputs from environment variables - Providing default values - Type conversion (boolean, integer, float) +- Detached Tier 2 input snapshots +- Explicit input replacement - Input freezing and thawing Run with: - python -m examples.basic_usage + python examples/inputs/basic_usage.py """ from __future__ import annotations @@ -38,13 +40,19 @@ def main() -> None: inputs.get_input("PORT", is_integer=True) inputs.get_input("TIMEOUT", is_float=True) inputs.get_input("NAME") + inputs.inputs["NAME"].to_snake_case() + inputs.snapshot_inputs()["NAME"].to_snake_case() # Demonstrate default values inputs.get_input("LOG_LEVEL", default="INFO") + # Replace active inputs with a new promoted snapshot + inputs.replace_inputs({"SERVICE": {"name": "api"}}, clear_frozen=True) + inputs.snapshot_inputs()["SERVICE"]["name"].upper_first() + # Demonstrate freeze/thaw functionality inputs.freeze_inputs() - + inputs.snapshot_inputs(frozen=True)["SERVICE"]["name"].upper_first() inputs.thaw_inputs() diff --git a/examples/inputs/decorator_api.py b/examples/inputs/decorator_api.py index 1897e60..57e6989 100644 --- a/examples/inputs/decorator_api.py +++ b/examples/inputs/decorator_api.py @@ -8,7 +8,7 @@ - JSON decoding from inputs Run with: - python -m examples.decorator_api + python examples/inputs/decorator_api.py """ from __future__ import annotations @@ -26,6 +26,7 @@ class UserService: """Example service demonstrating decorator-based input handling.""" + @input_config("user_id", source_name="USER_ID", required=True) def get_user(self, user_id: str) -> dict[str, str]: """Get a user by ID. @@ -41,9 +42,9 @@ def authenticated_call(self, api_key: str, endpoint: str = "/users") -> str: The api_key is required and sourced from API_KEY input. The endpoint parameter uses its default if not in inputs. """ - return f"Calling {endpoint} with key {api_key[:4]}..." + return f"Calling {endpoint} with configured API key" - @input_config("config", decode_from_json=True) + @input_config("config", source_name="CONFIG", decode_from_json=True) def parse_config(self, config: dict[str, str] | None = None) -> dict[str, str]: """Parse configuration from JSON input. @@ -51,7 +52,7 @@ def parse_config(self, config: dict[str, str] | None = None) -> dict[str, str]: """ return config or {} - @input_config("port", is_integer=True, default=8080) + @input_config("port", source_name="PORT", is_integer=True, default=8080) def get_port(self, port: int) -> int: """Get the configured port. diff --git a/examples/inputs/encoding_decoding.py b/examples/inputs/encoding_decoding.py index 28c3a0f..f590dc2 100644 --- a/examples/inputs/encoding_decoding.py +++ b/examples/inputs/encoding_decoding.py @@ -8,7 +8,7 @@ - Combined Base64 + JSON/YAML decoding Run with: - python -m examples.encoding_decoding + python examples/inputs/encoding_decoding.py """ from __future__ import annotations @@ -21,7 +21,7 @@ def main() -> None: """Demonstrate encoding/decoding features.""" # Prepare encoded test data - json_data = '{"database": "postgres", "port": 5432}' + json_data = '{"database": "postgres", "port": "5432", "enabled": "true"}' yaml_data = "server:\n host: localhost\n port: 8080" base64_json = base64.b64encode(json_data.encode()).decode() base64_yaml = base64.b64encode(yaml_data.encode()).decode() @@ -38,34 +38,42 @@ def main() -> None: ) # JSON decoding - inputs.decode_input("json_config", decode_from_json=True) + json_config = inputs.decode_input("json_config", decode_from_json=True, as_extended=True) + json_config.reconstruct_special_types().to_export_safe() # YAML decoding - inputs.decode_input("yaml_config", decode_from_yaml=True) + yaml_config = inputs.decode_input("yaml_config", decode_from_yaml=True, as_extended=True) + yaml_config["server"]["host"].upper_first() # Base64 + JSON decoding - inputs.decode_input( + base64_decoded_json = inputs.decode_input( "base64_json_config", decode_from_base64=True, decode_from_json=True, + as_extended=True, ) + base64_decoded_json.wrap_for_export(allow_encoding="json") # Base64 + YAML decoding - inputs.decode_input( + base64_decoded_yaml = inputs.decode_input( "base64_yaml_config", decode_from_base64=True, decode_from_yaml=True, + as_extended=True, ) + base64_decoded_yaml.to_export_safe() # Plain text (no decoding) - inputs.get_input("plain_text") + inputs.get_input("plain_text", as_extended=True).upper_first() # Missing input with default - inputs.decode_input( + fallback = inputs.decode_input( "nonexistent", - default={"fallback": True}, + default={"fallback": "true"}, decode_from_json=True, + as_extended=True, ) + fallback.reconstruct_special_types() if __name__ == "__main__": diff --git a/examples/logging/README.md b/examples/logging/README.md index 5317e58..e00823c 100644 --- a/examples/logging/README.md +++ b/examples/logging/README.md @@ -1,6 +1,11 @@ -# LifecycleLogging Examples +# Logging Examples -This directory contains working examples demonstrating the features of the `extended_data.logging` package. +This directory contains working examples for structured lifecycle logging in +`extended_data.logging`. + +`Logging` does not write log files by default. Pass `enable_file=True` with an +optional `log_file_name`, or set `OVERRIDE_TO_FILE=True`, when a workflow should +create file output. ## Examples @@ -13,7 +18,7 @@ Demonstrates fundamental logging capabilities: - Adding identifiers to messages ```bash -python examples/basic_logging.py +uv run python examples/logging/basic_logging.py ``` ### markers_and_storage.py @@ -24,7 +29,7 @@ Shows how to use markers for message organization: - Combining both marker types ```bash -python examples/markers_and_storage.py +uv run python examples/logging/markers_and_storage.py ``` ### verbosity_control.py @@ -35,7 +40,7 @@ Demonstrates verbosity settings: - Registering bypass markers that ignore verbosity settings ```bash -python examples/verbosity_control.py +uv run python examples/logging/verbosity_control.py ``` ### exit_run_formatting.py @@ -47,22 +52,22 @@ Shows result formatting and transformation: - Custom transform functions ```bash -python examples/exit_run_formatting.py +uv run python examples/logging/exit_run_formatting.py ``` ## Running the Examples 1. Install the package: ```bash - pip install extended_data.logging + pip install extended-data ``` 2. Run any example: ```bash - python examples/.py + python examples/logging/.py ``` Or from the repository root: ```bash -uv run python examples/.py +uv run python examples/logging/.py ``` diff --git a/examples/logging/exit_run_formatting.py b/examples/logging/exit_run_formatting.py index 9ee05cc..46e7f87 100644 --- a/examples/logging/exit_run_formatting.py +++ b/examples/logging/exit_run_formatting.py @@ -22,14 +22,11 @@ def main() -> None: } logger.exit_run(results, key_transform="snake_case", exit_on_completion=False) - # Example 2: Using unhump_results (shorthand for snake_case) - logger.exit_run(results, unhump_results=True, exit_on_completion=False) - - # Example 3: Transform to camelCase + # Example 2: Transform to camelCase snake_results = {"user_name": "john_doe", "email_address": "john@example.com"} logger.exit_run(snake_results, key_transform="camel_case", exit_on_completion=False) - # Example 4: Nested key transformation + # Example 3: Nested key transformation nested_results = { "userData": { "firstName": "John", @@ -39,7 +36,7 @@ def main() -> None: } logger.exit_run(nested_results, key_transform="snake_case", exit_on_completion=False) - # Example 5: Adding prefix to keys + # Example 4: Adding prefix to keys field_results = {"item1": {"fieldName": "value1", "otherField": "value2"}} logger.exit_run( field_results, @@ -47,7 +44,7 @@ def main() -> None: exit_on_completion=False, ) - # Example 6: Custom transform function + # Example 5: Custom transform function logger.exit_run( {"myKey": "value", "anotherKey": "data"}, key_transform=lambda k: k.upper(), diff --git a/examples/logging/markers_and_storage.py b/examples/logging/markers_and_storage.py index 6ab8a99..3509954 100644 --- a/examples/logging/markers_and_storage.py +++ b/examples/logging/markers_and_storage.py @@ -53,8 +53,8 @@ def main() -> None: log_level="info", ) - # Access stored messages - for messages in logger.stored_messages.values(): + # Access stored messages through a detached promoted snapshot + for messages in logger.snapshot_stored_messages().values(): for _msg in messages: print(_msg) diff --git a/pyproject.toml b/pyproject.toml index 0304bb1..80b5653 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "extended-data" version = "7.0.0" -description = "Comprehensive Python data utilities for serialization, inputs, logging, vendor data, and workflows" +description = "Comprehensive Python data utilities for serialization, inputs, logging, external data, and workflows" requires-python = ">=3.10" license = { text = "MIT" } readme = "README.md" @@ -35,7 +35,6 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "case-insensitive-dictionary>=0.2.1", "deepmerge>=2.0", "gitpython>=3.1.0", "httpx>=0.28.1", @@ -52,6 +51,7 @@ dependencies = [ "sortedcontainers>=2.4.0", "tenacity>=8.4.1,<9.0.0", "tomlkit>=0.13.2", + "typing-extensions>=4.12.2", "validators>=0.22.0", "wrapt>=1.16.0", ] @@ -89,19 +89,13 @@ langchain = [ "langchain-core>=1.3.0", "langsmith>=0.7.33", ] -crewai = [ - "crewai[tools]>=1.14.2rc1", - "uv>=0.11.7", -] strands = ["strands-agents>=1.36.0"] mcp = ["mcp>=1.26.0,<1.27.dev0"] ai = [ - "crewai[tools]>=1.14.2rc1", "langchain-core>=1.3.0", "langsmith>=0.7.33", "mcp>=1.26.0,<1.27.dev0", "strands-agents>=1.36.0", - "uv>=0.11.7", ] webhooks = [ "fastapi>=0.136.0", @@ -109,7 +103,6 @@ webhooks = [ "uvicorn>=0.45.0", ] vector = [ - "sentence-transformers>=5.4.1", "sqlite-vec>=0.1.9", ] tests = [ @@ -135,7 +128,6 @@ dev = [ all = [ "anthropic>=0.96.0", "boto3>=1.42.92", - "crewai[tools]>=1.14.2rc1", "fastapi>=0.136.0", "filelock>=3.29.0", "google-api-python-client>=2.194.0", @@ -149,25 +141,23 @@ all = [ "PyGithub>=2.9.1", "pyngrok>=8.0.0", "python-graphql-client>=0.4.3", - "sentence-transformers>=5.4.1", + "pyyaml>=6.0.3", + "rich>=13.7.0,<15.0.0", "slack-sdk>=3.41.0", "sqlite-vec>=0.1.9", "strands-agents>=1.36.0", - "uv>=0.11.7", "uvicorn>=0.45.0", + "validators>=0.35.0", ] [project.scripts] -extended-data = "extended_data.connectors.cli:main" +extended-data = "extended_data.cli:main" extended-data-mcp = "extended_data.connectors.mcp:main" meshy-mcp = "extended_data.connectors.meshy.mcp:main" [project.entry-points."extended_data.connectors"] jules = "extended_data.connectors.google.jules:JulesConnector" google = "extended_data.connectors.google:GoogleConnector" -google_cloud = "extended_data.connectors.google:GoogleCloudConnector" -google_workspace = "extended_data.connectors.google:GoogleWorkspaceConnector" -google_billing = "extended_data.connectors.google:GoogleBillingConnector" cursor = "extended_data.connectors.cursor:CursorConnector" github = "extended_data.connectors.github:GitHubConnector" meshy = "extended_data.connectors.meshy:MeshyConnector" diff --git a/src/extended_data/__init__.py b/src/extended_data/__init__.py index c95bbea..a92226b 100644 --- a/src/extended_data/__init__.py +++ b/src/extended_data/__init__.py @@ -1,22 +1,32 @@ """Extended Data. This package provides Python utilities for structured data primitives, inputs, -logging, vendor data connectors, and workflow-oriented integrations. +logging, external data connectors, and workflow-oriented integrations. """ from __future__ import annotations import importlib -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from extended_data._version import __version__ -from extended_data.base64_utils import base64_decode, base64_encode -from extended_data.export_utils import ( +from extended_data.containers import ( + ExtendedDict, + ExtendedList, + ExtendedSet, + ExtendedString, + ExtendedTuple, + extend_data, + to_builtin, +) +from extended_data.io.base64 import base64_decode, base64_encode +from extended_data.io.exporters import ( make_raw_data_export_safe, wrap_raw_data_for_export, ) -from extended_data.file_data_type import ( +from extended_data.io.files import ( + DataFile, FilePath, clone_repository_to_temp, decode_file, @@ -29,122 +39,96 @@ get_tld, is_url, match_file_extensions, + read_data_file, read_file, resolve_local_path, write_file, ) -from extended_data.hcl2_utils import decode_hcl2, encode_hcl2 -from extended_data.import_utils import unwrap_raw_data_from_import -from extended_data.json_utils import decode_json, encode_json -from extended_data.list_data_type import filter_list, flatten_list -from extended_data.map_data_type import ( - SortedDefaultDict, - all_values_from_map, - create_merger, - deduplicate_map, - deep_merge, - filter_map, - first_non_empty_value_from_map, - flatten_map, - get_default_dict, - unhump_map, - zipmap, -) -from extended_data.matcher_utils import is_non_empty_match, is_partial_match -from extended_data.splitter_utils import split_dict_by_type, split_list_by_type -from extended_data.stack_utils import ( - filter_methods, - get_available_methods, - get_caller, - get_inputs_from_docstring, - get_unique_signature, - update_docstring, -) -from extended_data.state_utils import ( - all_non_empty, - all_non_empty_in_dict, - all_non_empty_in_list, - any_non_empty, - are_nothing, - first_non_empty, - is_nothing, - yield_non_empty, -) -from extended_data.string_data_type import ( - bytestostr, - lower_first_char, - removeprefix, - removesuffix, - sanitize_key, - titleize_name, - truncate, - upper_first_char, -) -from extended_data.string_transformations import ( - humanize, - ordinalize, - pluralize, - singularize, - titleize, - to_camel_case, - to_kebab_case, - to_pascal_case, - to_snake_case, -) -from extended_data.toml_utils import decode_toml, encode_toml -from extended_data.type_utils import ( - convert_special_type, - convert_special_types, - get_default_value_for_type, - get_primitive_type_for_instance_type, - make_hashable, - reconstruct_special_type, - reconstruct_special_types, - strtobool, - strtodate, - strtodatetime, - strtofloat, - strtoint, - strtopath, - strtotime, - typeof, +from extended_data.io.importers import unwrap_raw_data_from_import +from extended_data.primitives.formats.errors import DataDecodeError +from extended_data.workflows import ( + DATA_TRANSFORM_STEPS, + DataWorkflow, + StepLike, + WorkflowAction, + WorkflowResult, + WorkflowStep, + data_transform_action, + list_data_transform_steps, ) -from extended_data.yaml_utils import decode_yaml, encode_yaml, is_yaml_data if TYPE_CHECKING: from extended_data.connectors import ( + AnthropicConnector, + AWSConnector, + ConnectorBase, ConnectorFabric, - VendorConnectorBase, + ConnectorInfo, + CursorConnector, + GitHubConnector, + GoogleConnector, + JulesConnector, + MeshyConnector, + SlackConnector, + VaultConnector, + ZoomConnector, get_connector, get_connector_class, get_connector_info, + list_available_connectors, + list_connector_capabilities, + list_connector_categories, list_connector_info, list_connectors, + list_connectors_by_capability, + list_connectors_by_category, ) from extended_data.inputs import InputProvider, directed_inputs, input_config from extended_data.logging import ExitRunError, KeyTransform, Logging + from extended_data.secrets import OutputFormat, SecretsConnector, SyncOperation, SyncOptions, SyncResult _LAZY_EXPORTS = { + "AWSConnector": ("extended_data.connectors", "AWSConnector"), + "AnthropicConnector": ("extended_data.connectors", "AnthropicConnector"), "ConnectorFabric": ("extended_data.connectors", "ConnectorFabric"), + "ConnectorInfo": ("extended_data.connectors", "ConnectorInfo"), + "CursorConnector": ("extended_data.connectors", "CursorConnector"), "ExitRunError": ("extended_data.logging", "ExitRunError"), + "GitHubConnector": ("extended_data.connectors", "GitHubConnector"), + "GoogleConnector": ("extended_data.connectors", "GoogleConnector"), "InputProvider": ("extended_data.inputs", "InputProvider"), + "JulesConnector": ("extended_data.connectors", "JulesConnector"), "KeyTransform": ("extended_data.logging", "KeyTransform"), "Logging": ("extended_data.logging", "Logging"), - "VendorConnectorBase": ("extended_data.connectors", "VendorConnectorBase"), + "MeshyConnector": ("extended_data.connectors", "MeshyConnector"), + "OutputFormat": ("extended_data.secrets", "OutputFormat"), + "SecretsConnector": ("extended_data.secrets", "SecretsConnector"), + "SlackConnector": ("extended_data.connectors", "SlackConnector"), + "SyncOperation": ("extended_data.secrets", "SyncOperation"), + "SyncOptions": ("extended_data.secrets", "SyncOptions"), + "SyncResult": ("extended_data.secrets", "SyncResult"), + "VaultConnector": ("extended_data.connectors", "VaultConnector"), + "ConnectorBase": ("extended_data.connectors", "ConnectorBase"), + "ZoomConnector": ("extended_data.connectors", "ZoomConnector"), "directed_inputs": ("extended_data.inputs", "directed_inputs"), "get_connector": ("extended_data.connectors", "get_connector"), "get_connector_class": ("extended_data.connectors", "get_connector_class"), "get_connector_info": ("extended_data.connectors", "get_connector_info"), "input_config": ("extended_data.inputs", "input_config"), + "list_available_connectors": ("extended_data.connectors", "list_available_connectors"), + "list_connector_capabilities": ("extended_data.connectors", "list_connector_capabilities"), + "list_connector_categories": ("extended_data.connectors", "list_connector_categories"), "list_connector_info": ("extended_data.connectors", "list_connector_info"), "list_connectors": ("extended_data.connectors", "list_connectors"), + "list_connectors_by_capability": ("extended_data.connectors", "list_connectors_by_capability"), + "list_connectors_by_category": ("extended_data.connectors", "list_connectors_by_category"), } -def __getattr__(name: str): - """Lazily expose integrated subpackage primitives at the package root.""" +def __getattr__(name: str) -> Any: + """Lazily expose integrated connectors and processors at the package root.""" if name not in _LAZY_EXPORTS: raise AttributeError(f"module {__name__!r} has no attribute {name!r}") @@ -155,110 +139,77 @@ def __getattr__(name: str): __all__ = [ + "DATA_TRANSFORM_STEPS", + "AWSConnector", + "AnthropicConnector", + "ConnectorBase", "ConnectorFabric", + "ConnectorInfo", + "CursorConnector", + "DataDecodeError", + "DataFile", + "DataWorkflow", "ExitRunError", + "ExtendedDict", + "ExtendedList", + "ExtendedSet", + "ExtendedString", + "ExtendedTuple", "FilePath", + "GitHubConnector", + "GoogleConnector", "InputProvider", + "JulesConnector", "KeyTransform", "Logging", - "SortedDefaultDict", - "VendorConnectorBase", + "MeshyConnector", + "OutputFormat", + "SecretsConnector", + "SlackConnector", + "StepLike", + "SyncOperation", + "SyncOptions", + "SyncResult", + "VaultConnector", + "WorkflowAction", + "WorkflowResult", + "WorkflowStep", + "ZoomConnector", "__version__", - "all_non_empty", - "all_non_empty_in_dict", - "all_non_empty_in_list", - "all_values_from_map", - "any_non_empty", - "are_nothing", "base64_decode", "base64_encode", - "bytestostr", "clone_repository_to_temp", - "convert_special_type", - "convert_special_types", - "create_merger", + "data_transform_action", "decode_file", - "decode_hcl2", - "decode_json", - "decode_toml", - "decode_yaml", - "deduplicate_map", - "deep_merge", "delete_file", "directed_inputs", - "encode_hcl2", - "encode_json", - "encode_toml", - "encode_yaml", + "extend_data", "file_path_depth", "file_path_rel_to_root", - "filter_list", - "filter_map", - "filter_methods", - "first_non_empty", - "first_non_empty_value_from_map", - "flatten_list", - "flatten_map", - "get_available_methods", - "get_caller", "get_connector", "get_connector_class", "get_connector_info", - "get_default_dict", - "get_default_value_for_type", "get_encoding_for_file_path", - "get_inputs_from_docstring", "get_parent_repository", - "get_primitive_type_for_instance_type", "get_repository_name", "get_tld", - "get_unique_signature", - "humanize", "input_config", - "is_non_empty_match", - "is_nothing", - "is_partial_match", "is_url", - "is_yaml_data", + "list_available_connectors", + "list_connector_capabilities", + "list_connector_categories", "list_connector_info", "list_connectors", - "lower_first_char", - "make_hashable", + "list_connectors_by_capability", + "list_connectors_by_category", + "list_data_transform_steps", "make_raw_data_export_safe", "match_file_extensions", - "ordinalize", - "pluralize", + "read_data_file", "read_file", - "reconstruct_special_type", - "reconstruct_special_types", - "removeprefix", - "removesuffix", "resolve_local_path", - "sanitize_key", - "singularize", - "split_dict_by_type", - "split_list_by_type", - "strtobool", - "strtodate", - "strtodatetime", - "strtofloat", - "strtoint", - "strtopath", - "strtotime", - "titleize", - "titleize_name", - "to_camel_case", - "to_kebab_case", - "to_pascal_case", - "to_snake_case", - "truncate", - "typeof", - "unhump_map", + "to_builtin", "unwrap_raw_data_from_import", - "update_docstring", - "upper_first_char", "wrap_raw_data_for_export", "write_file", - "yield_non_empty", - "zipmap", ] diff --git a/src/extended_data/cli.py b/src/extended_data/cli.py new file mode 100644 index 0000000..c763e4d --- /dev/null +++ b/src/extended_data/cli.py @@ -0,0 +1,212 @@ +"""Top-level command line interface for Extended Data.""" + +from __future__ import annotations + +import argparse +import sys + +from collections.abc import Sequence +from typing import Any, cast + +from extended_data.io import DataFile +from extended_data.primitives.redaction import redact_sensitive_text +from extended_data.workflows import DataWorkflow, WorkflowResult, list_data_transform_steps + + +CONNECTOR_COMMANDS = frozenset({"call", "info", "list", "mcp", "methods"}) +OUTPUT_ENCODINGS = ("json", "yaml", "toml", "hcl", "raw") + + +def _write_stdout(message: str) -> None: + """Write one CLI output line.""" + sys.stdout.write(f"{message}\n") + + +def _write_stderr(message: str) -> None: + """Write one CLI error line.""" + sys.stderr.write(f"{redact_sensitive_text(message)}\n") + + +def _decode_artifact(args: argparse.Namespace) -> DataFile: + """Decode an inline payload or file path into a DataFile artifact.""" + value = getattr(args, "value", None) + file_path = getattr(args, "file_path", None) + + if value is not None and file_path is not None: + raise ValueError("pass either VALUE or --file, not both") + if value is None and file_path is None: + raise ValueError("pass VALUE or --file") + if file_path is not None: + return DataFile.read(file_path, suffix=args.suffix) + return DataFile.decode(cast(str, value), suffix=args.suffix) + + +def cmd_decode(args: argparse.Namespace) -> int: + """Decode structured data and write it through the shared export boundary.""" + try: + artifact = _decode_artifact(args) + _write_stdout(artifact.wrap_for_export(allow_encoding=args.output, **_json_format_opts(args))) + return 0 + except Exception as e: + _write_stderr(str(e)) + return 1 + + +def cmd_inspect(args: argparse.Namespace) -> int: + """Decode structured data and write its DataFile metadata.""" + try: + artifact = _decode_artifact(args) + _write_stdout(artifact.metadata.wrap_for_export(allow_encoding=args.output, **_json_format_opts(args))) + return 0 + except Exception as e: + _write_stderr(str(e)) + return 1 + + +def _json_format_opts(args: argparse.Namespace) -> dict[str, Any]: + """Return common JSON formatting options for CLI export commands.""" + if args.output == "json" and not args.compact: + return {"indent_2": True} + return {} + + +def _merge_workflow(args: argparse.Namespace) -> DataWorkflow: + """Build a layered merge workflow from CLI arguments.""" + file_paths = args.file_paths + if len(file_paths) < 2: + raise ValueError("merge requires at least two files") + + workflow = DataWorkflow.from_file(file_paths[0], suffix=args.suffix) + for file_path in file_paths[1:]: + workflow = workflow.merge_file(file_path, suffix=args.suffix) + return workflow + + +def cmd_merge(args: argparse.Namespace) -> int: + """Merge structured files through DataWorkflow and write or print the result.""" + try: + workflow = _merge_workflow(args) + result: WorkflowResult + if args.write: + result = workflow.write(args.write, encoding=args.output, allow_empty=args.allow_empty) + else: + result = workflow.result() + _write_stdout(result.wrap_for_export(allow_encoding=args.output, **_json_format_opts(args))) + return 0 + except Exception as e: + _write_stderr(str(e)) + return 1 + + +def _transform_workflow(args: argparse.Namespace) -> DataWorkflow: + """Build a workflow that applies named Tier 2 transforms.""" + steps = args.steps or [] + if not steps: + raise ValueError("transform requires at least one --step") + + return _decode_artifact(args).workflow().transform(*steps) + + +def cmd_transform(args: argparse.Namespace) -> int: + """Apply named Tier 2 transforms through DataWorkflow.""" + try: + workflow = _transform_workflow(args) + result: WorkflowResult + if args.write: + result = workflow.write(args.write, encoding=args.output, allow_empty=args.allow_empty) + else: + result = workflow.result() + _write_stdout(result.wrap_for_export(allow_encoding=args.output, **_json_format_opts(args))) + return 0 + except Exception as e: + _write_stderr(str(e)) + return 1 + + +def _build_parser() -> argparse.ArgumentParser: + """Build the top-level Extended Data argument parser.""" + parser = argparse.ArgumentParser( + prog="extended-data", + description="CLI for Extended Data primitives, files, workflows, and connectors", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + extended-data decode '{"service": {"name": "api"}}' --suffix json + extended-data decode --file config.yaml --output json + extended-data inspect --file config.yaml + extended-data merge base.yaml env.yaml --output yaml + extended-data transform --file payload.json --step reconstruct --step unhump + extended-data list --category cloud + extended-data call github get_repository_file --path service.json --json + """, + ) + subparsers = parser.add_subparsers(dest="command", help="Commands") + + decode_parser = subparsers.add_parser("decode", help="Decode inline data or a file") + decode_parser.add_argument("value", nargs="?", help="Inline payload to decode") + decode_parser.add_argument("--file", dest="file_path", help="File path or URL to decode") + decode_parser.add_argument("--suffix", help="Input format override") + decode_parser.add_argument("--output", choices=OUTPUT_ENCODINGS, default="json", help="Output encoding") + decode_parser.add_argument("--compact", action="store_true", help="Compact JSON output") + decode_parser.set_defaults(func=cmd_decode) + + inspect_parser = subparsers.add_parser("inspect", help="Decode data and print artifact metadata") + inspect_parser.add_argument("value", nargs="?", help="Inline payload to inspect") + inspect_parser.add_argument("--file", dest="file_path", help="File path or URL to inspect") + inspect_parser.add_argument("--suffix", help="Input format override") + inspect_parser.add_argument("--output", choices=OUTPUT_ENCODINGS, default="json", help="Output encoding") + inspect_parser.add_argument("--compact", action="store_true", help="Compact JSON output") + inspect_parser.set_defaults(func=cmd_inspect) + + merge_parser = subparsers.add_parser("merge", help="Deep merge structured files") + merge_parser.add_argument("file_paths", nargs="+", help="Structured files to merge in order") + merge_parser.add_argument("--suffix", help="Input format override for all files") + merge_parser.add_argument("--output", choices=OUTPUT_ENCODINGS, default="json", help="Output encoding") + merge_parser.add_argument("--compact", action="store_true", help="Compact JSON output") + merge_parser.add_argument("--write", help="Write merged output to this file") + merge_parser.add_argument("--allow-empty", action="store_true", help="Allow writing empty merged output") + merge_parser.set_defaults(func=cmd_merge) + + transform_parser = subparsers.add_parser("transform", help="Apply named Extended Data transforms") + transform_parser.add_argument("value", nargs="?", help="Inline payload to transform") + transform_parser.add_argument("--file", dest="file_path", help="File path or URL to transform") + transform_parser.add_argument("--suffix", help="Input format override") + transform_parser.add_argument( + "--step", + dest="steps", + action="append", + choices=list_data_transform_steps(), + help="Transform step to apply in order", + ) + transform_parser.add_argument("--output", choices=OUTPUT_ENCODINGS, default="json", help="Output encoding") + transform_parser.add_argument("--compact", action="store_true", help="Compact JSON output") + transform_parser.add_argument("--write", help="Write transformed output to this file") + transform_parser.add_argument("--allow-empty", action="store_true", help="Allow writing empty transformed output") + transform_parser.set_defaults(func=cmd_transform) + + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + """Run the Extended Data CLI.""" + args = list(argv) if argv is not None else sys.argv[1:] + if args and args[0] in CONNECTOR_COMMANDS: + from extended_data.connectors.cli import main as connectors_main + + return connectors_main(args) + + parser = _build_parser() + parsed = parser.parse_args(args) + + if not parsed.command: + parser.print_help() + return 0 + + try: + return parsed.func(parsed) + except KeyboardInterrupt: + return 130 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/extended_data/connectors/__init__.py b/src/extended_data/connectors/__init__.py index 80d1cac..87331c2 100644 --- a/src/extended_data/connectors/__init__.py +++ b/src/extended_data/connectors/__init__.py @@ -1,9 +1,9 @@ """Extended Data Connectors - shared connectors for cloud, SaaS, and AI platforms. This package provides modular connectors for various cloud providers and services: -- Anthropic: Claude AI API and Agent SDK (NEW) +- Anthropic: Claude AI API and Agent SDK - AWS: Organizations, SSO/Identity Center, S3, Secrets Manager -- Cursor: Background Agent API for AI coding agents (NEW) +- Cursor: Background Agent API for AI coding agents - Google Cloud: Workspace, Cloud Platform, Billing, Services (GKE, Compute, etc.) - GitHub: Repository operations, PR management - Meshy: 3D asset generation @@ -12,13 +12,9 @@ - Zoom: User and meeting management Usage: - # Basic connector (session management + secrets) + # AWS connector with session management, secrets, Organizations, SSO, and S3 from extended_data.connectors import AWSConnector connector = AWSConnector() - - # Full connector with all operations - from extended_data.connectors.aws import AWSConnectorFull - connector = AWSConnectorFull() accounts = connector.get_accounts() # Cursor AI agents @@ -31,10 +27,10 @@ anthropic = AnthropicConnector() response = anthropic.create_message(...) - # Mixin approach for custom connectors - from extended_data.connectors.aws import AWSConnector, AWSOrganizationsMixin + # Custom connector behavior can subclass the unified connector + from extended_data.connectors.aws import AWSConnector - class MyConnector(AWSConnector, AWSOrganizationsMixin): + class MyConnector(AWSConnector): pass # Meshy AI 3D generation (functional interface) @@ -53,9 +49,16 @@ class MyConnector(AWSConnector, AWSOrganizationsMixin): from extended_data._version import __version__ -# Core imports (always available) +# Core package primitives from extended_data.connectors import meshy -from extended_data.connectors.base import VendorConnectorBase +from extended_data.connectors.anthropic import AnthropicConnector +from extended_data.connectors.aws import ( + AWSConnector, + AWSOrganizationsMixin, + AWSS3Mixin, + AWSSSOmixin, +) +from extended_data.connectors.base import ConnectorBase from extended_data.connectors.cloud_params import ( get_aws_call_params, get_cloud_call_params, @@ -63,91 +66,45 @@ class MyConnector(AWSConnector, AWSOrganizationsMixin): ) from extended_data.connectors.connectors import ConnectorFabric -# Connectors with no extra dependencies (always available) +# Built-in connector classes; optional SDKs are loaded by connector instances. from extended_data.connectors.cursor import CursorConnector +from extended_data.connectors.github import GitHubConnector +from extended_data.connectors.google import ( + GoogleBillingMixin, + GoogleCloudMixin, + GoogleConnector, + GoogleServicesMixin, + GoogleWorkspaceMixin, + JulesConnector, +) +from extended_data.connectors.meshy import MeshyConnector +from extended_data.connectors.secrets import SecretsConnector +from extended_data.connectors.slack import SlackConnector +from extended_data.connectors.vault import VaultConnector from extended_data.connectors.zoom import ZoomConnector -# Optional connectors - wrapped in try/except for graceful degradation -# These require optional dependencies: pip install extended-data[] - -# Anthropic connector (requires: pip install extended-data[anthropic]) -try: - from extended_data.connectors.anthropic import AnthropicConnector -except ImportError: - AnthropicConnector = None # type: ignore[misc, assignment] - -# AWS connector (requires: pip install extended-data[aws]) -try: - from extended_data.connectors.aws import ( - AWSConnector, - AWSConnectorFull, - AWSOrganizationsMixin, - AWSS3Mixin, - AWSSSOmixin, - ) -except ImportError: - AWSConnector = None # type: ignore[misc, assignment] - AWSConnectorFull = None # type: ignore[misc, assignment] - AWSOrganizationsMixin = None # type: ignore[misc, assignment] - AWSS3Mixin = None # type: ignore[misc, assignment] - AWSSSOmixin = None # type: ignore[misc, assignment] - -# GitHub connector (requires: pip install extended-data[github]) -try: - from extended_data.connectors.github import GitHubConnector -except ImportError: - GitHubConnector = None # type: ignore[misc, assignment] - -# Google connector (requires: pip install extended-data[google]) -try: - from extended_data.connectors.google import ( - GoogleBillingMixin, - GoogleCloudMixin, - GoogleConnector, - GoogleConnectorFull, - GoogleServicesMixin, - GoogleWorkspaceMixin, - ) -except ImportError: - GoogleConnector = None # type: ignore[misc, assignment] - GoogleConnectorFull = None # type: ignore[misc, assignment] - GoogleWorkspaceMixin = None # type: ignore[misc, assignment] - GoogleCloudMixin = None # type: ignore[misc, assignment] - GoogleBillingMixin = None # type: ignore[misc, assignment] - GoogleServicesMixin = None # type: ignore[misc, assignment] - -# Slack connector (requires: pip install extended-data[slack]) -try: - from extended_data.connectors.slack import SlackConnector -except ImportError: - SlackConnector = None # type: ignore[misc, assignment] - -# Vault connector (requires: pip install extended-data[vault]) -try: - from extended_data.connectors.vault import VaultConnector -except ImportError: - VaultConnector = None # type: ignore[misc, assignment] - __all__ = [ "AWSConnector", - "AWSConnectorFull", "AWSOrganizationsMixin", "AWSS3Mixin", "AWSSSOmixin", "AnthropicConnector", + "ConnectorBase", "ConnectorFabric", + "ConnectorInfo", "CursorConnector", "GitHubConnector", "GoogleBillingMixin", "GoogleCloudMixin", "GoogleConnector", - "GoogleConnectorFull", "GoogleServicesMixin", "GoogleWorkspaceMixin", + "JulesConnector", + "MeshyConnector", + "SecretsConnector", "SlackConnector", "VaultConnector", - "VendorConnectorBase", "ZoomConnector", "__version__", "get_aws_call_params", @@ -156,16 +113,27 @@ class MyConnector(AWSConnector, AWSOrganizationsMixin): "get_connector_class", "get_connector_info", "get_google_call_params", + "list_available_connectors", + "list_connector_capabilities", + "list_connector_categories", "list_connector_info", "list_connectors", + "list_connectors_by_capability", + "list_connectors_by_category", "meshy", ] # Registry - unified access to all connectors from extended_data.connectors.registry import ( + ConnectorInfo, get_connector, get_connector_class, get_connector_info, + list_available_connectors, + list_connector_capabilities, + list_connector_categories, list_connector_info, list_connectors, + list_connectors_by_capability, + list_connectors_by_category, ) diff --git a/src/extended_data/connectors/_optional.py b/src/extended_data/connectors/_optional.py index bc3b496..b24ad31 100644 --- a/src/extended_data/connectors/_optional.py +++ b/src/extended_data/connectors/_optional.py @@ -18,12 +18,14 @@ import importlib -from typing import Any +from typing import Any, cast + +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString, extend_data # Mapping of package names to their extras PACKAGE_TO_EXTRA: dict[str, str] = { - # Vendor connectors + # Connector extras "boto3": "aws", "google.cloud": "google", "google.api_core": "google", @@ -39,19 +41,30 @@ # AI frameworks "langchain_core": "langchain", "langchain": "langchain", - "crewai": "crewai", "strands": "strands", "mcp": "mcp", # Features "fastapi": "webhooks", "uvicorn": "webhooks", "sqlite_vec": "vector", - "sentence_transformers": "vector", } # Cache for import checks _import_cache: dict[str, bool] = {} +PACKAGE_INSTALL_HINTS: dict[str, str] = { + "crewai": ( + "Install CrewAI separately after reviewing its dependency tree; extended-data does not publish a " + "CrewAI extra while current CrewAI releases pull vulnerable chromadb versions." + ), + "sentence_transformers": ( + "Install sentence-transformers separately after reviewing its dependency tree; extended-data does not " + "include it in the vector extra while current releases pull vulnerable torch versions." + ), +} + +CREWAI_TOOLS_IMPORT_ERROR = f"crewai is required for CrewAI tools.\n{PACKAGE_INSTALL_HINTS['crewai']}" + def is_available(package: str) -> bool: """Check if a package is available for import. @@ -74,7 +87,7 @@ def is_available(package: str) -> bool: return False -def get_extra_for_package(package: str) -> str | None: +def get_extra_for_package(package: str) -> ExtendedString | None: """Get the extra name for a package. Args: @@ -83,7 +96,10 @@ def get_extra_for_package(package: str) -> str | None: Returns: Extra name or None if not mapped """ - return PACKAGE_TO_EXTRA.get(package) + extra = PACKAGE_TO_EXTRA.get(package) + if extra is None: + return None + return ExtendedString(extra) def require_extra(package: str, extra: str | None = None) -> Any: @@ -102,13 +118,29 @@ def require_extra(package: str, extra: str | None = None) -> Any: try: return importlib.import_module(package) except ImportError as e: - extra_name = extra or get_extra_for_package(package) or package + if package in PACKAGE_INSTALL_HINTS: + raise ImportError(f"Package '{package}' is required but not installed.\n{PACKAGE_INSTALL_HINTS[package]}") from e + extra_name = str(extra or get_extra_for_package(package) or package) raise ImportError( f"Package '{package}' is required but not installed.\n" f"Install with: pip install extended-data[{extra_name}]" ) from e +def get_crewai_tool_decorator() -> Any: + """Import the CrewAI tool decorator with extended-data's install guidance.""" + try: + module = importlib.import_module("crewai.tools") + except ImportError as e: + raise ImportError(CREWAI_TOOLS_IMPORT_ERROR) from e + + try: + return module.tool + except AttributeError as e: + msg = "crewai.tools.tool is required for CrewAI tools, but the installed CrewAI package does not expose it." + raise ImportError(msg) from e + + def require_any(*packages: str, extra: str) -> Any: """Import the first available package from a list. @@ -138,45 +170,98 @@ def require_any(*packages: str, extra: str) -> Any: # === Framework Detection === -def detect_ai_frameworks() -> dict[str, bool]: +def detect_ai_frameworks() -> ExtendedDict: """Detect which AI frameworks are available. Returns: - Dict mapping framework name to availability + Extended dict mapping framework name to availability. """ - return { + return extend_data({ "langchain": is_available("langchain_core"), "crewai": is_available("crewai"), "strands": is_available("strands"), "mcp": is_available("mcp"), - } + }) -def get_available_ai_frameworks() -> list[str]: +def get_available_ai_frameworks() -> ExtendedList[ExtendedString]: """Get list of available AI frameworks. Returns: - List of framework names that are installed + Extended list of framework names that are installed. """ - return [name for name, available in detect_ai_frameworks().items() if available] + return extend_data([name for name, available in detect_ai_frameworks().items() if available]) # === Connector Availability === CONNECTOR_REQUIREMENTS: dict[str, list[str]] = { # Core-only connectors (always available) + "cursor": [], # httpx is in core "meshy": [], # httpx, pydantic, tenacity are in core + "secrets": [], # pyyaml is in core "zoom": [], # requests is in core - "cursor": [], # httpx is in core # Connectors requiring extras + "anthropic": ["anthropic"], "aws": ["boto3"], "google": ["googleapiclient"], "github": ["github"], + "jules": ["googleapiclient"], "slack": ["slack_sdk"], "vault": ["hvac"], - "anthropic": ["anthropic"], } +CONNECTOR_EXTRAS: dict[str, str] = { + "anthropic": "anthropic", + "aws": "aws", + "cursor": "cursor", + "google": "google", + "github": "github", + "jules": "google", + "meshy": "meshy", + "secrets": "secrets", + "slack": "slack", + "vault": "vault", + "zoom": "zoom", +} + + +def _normalize_connector_name(connector: str) -> str: + """Normalize connector names for optional dependency lookup.""" + return connector.strip().lower() + + +def get_extra_for_connector(connector: str) -> ExtendedString | None: + """Get the optional dependency extra for a connector.""" + extra = CONNECTOR_EXTRAS.get(_normalize_connector_name(connector)) + if extra is None: + return None + return ExtendedString(extra) + + +def get_connector_requirements(connector: str) -> ExtendedList[ExtendedString]: + """Get package imports required by a connector.""" + return cast( + ExtendedList[ExtendedString], + extend_data(list(CONNECTOR_REQUIREMENTS.get(_normalize_connector_name(connector), []))), + ) + + +def get_missing_connector_requirements(connector: str) -> ExtendedList[ExtendedString]: + """Get missing package imports for a connector.""" + return cast( + ExtendedList[ExtendedString], + extend_data([str(pkg) for pkg in get_connector_requirements(connector) if not is_available(str(pkg))]), + ) + + +def get_connector_install_command(connector: str) -> ExtendedString | None: + """Get the pip install command for a connector extra.""" + extra = get_extra_for_connector(connector) + if extra is None: + return None + return ExtendedString(f"pip install extended-data[{extra}]") + def is_connector_available(connector: str) -> bool: """Check if a connector's dependencies are available. @@ -187,17 +272,16 @@ def is_connector_available(connector: str) -> bool: Returns: True if all required packages are available """ - requirements = CONNECTOR_REQUIREMENTS.get(connector, []) - return all(is_available(pkg) for pkg in requirements) + return not get_missing_connector_requirements(connector) -def get_available_connectors() -> list[str]: +def get_available_connectors() -> ExtendedList[ExtendedString]: """Get list of connectors with available dependencies. Returns: - List of connector names that can be used + Extended list of connector names that can be used. """ - return [name for name in CONNECTOR_REQUIREMENTS if is_connector_available(name)] + return extend_data([name for name in CONNECTOR_REQUIREMENTS if is_connector_available(name)]) def require_connector(connector: str) -> None: @@ -209,12 +293,12 @@ def require_connector(connector: str) -> None: Raises: ImportError: With helpful message if dependencies missing """ - requirements = CONNECTOR_REQUIREMENTS.get(connector, []) - missing = [pkg for pkg in requirements if not is_available(pkg)] + missing = get_missing_connector_requirements(connector) if missing: + extra = get_extra_for_connector(connector) or connector raise ImportError( f"The '{connector}' connector requires additional dependencies.\n" - f"Missing packages: {', '.join(missing)}\n" - f"Install with: pip install extended-data[{connector}]" + f"Missing packages: {', '.join(str(package) for package in missing)}\n" + f"Install with: pip install extended-data[{extra}]" ) diff --git a/src/extended_data/connectors/ai_tools.py b/src/extended_data/connectors/ai_tools.py index 82435b6..a28558b 100644 --- a/src/extended_data/connectors/ai_tools.py +++ b/src/extended_data/connectors/ai_tools.py @@ -8,12 +8,16 @@ import builtins -from typing import Any +from collections.abc import Callable, Iterable, Mapping +from typing import Any, NoReturn, cast from pydantic import BaseModel +from extended_data.containers import ExtendedDict, extend_data +from extended_data.primitives.redaction import redact_sensitive_text -def get_pydantic_schema(model: builtins.type[BaseModel]) -> dict[str, Any]: + +def get_pydantic_schema(model: builtins.type[BaseModel]) -> ExtendedDict: """Generate a Vercel AI SDK-compatible JSON schema from a Pydantic model. This function removes the top-level 'title' and 'description' fields, @@ -25,7 +29,7 @@ def get_pydantic_schema(model: builtins.type[BaseModel]) -> dict[str, Any]: model: The Pydantic model class. Returns: - A JSON schema dictionary. + An extended JSON schema dictionary. """ schema = model.model_json_schema() @@ -33,4 +37,33 @@ def get_pydantic_schema(model: builtins.type[BaseModel]) -> dict[str, Any]: schema.pop("title", None) schema.pop("description", None) - return schema + return cast(ExtendedDict, extend_data(schema)) + + +def raise_unknown_tool_framework(framework: str) -> NoReturn: + """Raise a redacted unknown-framework diagnostic for AI tool factories.""" + safe_framework = redact_sensitive_text(framework) + msg = f"Unknown framework: {safe_framework}. Options: auto, langchain, crewai, strands" + raise ValueError(msg) + + +def build_langchain_tools(tool_definitions: Iterable[Mapping[str, Any]]) -> list[Any]: + """Build LangChain StructuredTools from connector tool definition mappings.""" + try: + from langchain_core.tools import StructuredTool + except ImportError as e: + msg = "langchain-core is required for LangChain tools.\nInstall with: pip install extended-data[langchain]" + raise ImportError(msg) from e + + tools: list[Any] = [] + for definition in tool_definitions: + args_schema = definition.get("schema") or definition.get("args_schema") + tools.append( + StructuredTool.from_function( + func=cast(Callable[..., Any], definition["func"]), + name=cast(str, definition["name"]), + description=cast(str, definition["description"]), + args_schema=cast(Any, args_schema), + ) + ) + return tools diff --git a/src/extended_data/connectors/anthropic/__init__.py b/src/extended_data/connectors/anthropic/__init__.py index 2a5a8a3..69b371b 100644 --- a/src/extended_data/connectors/anthropic/__init__.py +++ b/src/extended_data/connectors/anthropic/__init__.py @@ -27,15 +27,20 @@ import os +from collections.abc import Mapping from dataclasses import dataclass from datetime import datetime from enum import Enum from typing import TYPE_CHECKING, Any -from pydantic import BaseModel, ConfigDict, Field +import httpx -from extended_data.connectors.base import VendorConnectorBase +from pydantic import BaseModel, ConfigDict, Field, ValidationError + +from extended_data.connectors.base import ConnectorBase +from extended_data.containers import ExtendedDict, ExtendedList, extend_data, to_builtin from extended_data.logging import Logging +from extended_data.primitives.redaction import redact_sensitive_text if TYPE_CHECKING: @@ -193,7 +198,7 @@ class AgentExecutionResult: # ============================================================================= -class AnthropicConnector(VendorConnectorBase): +class AnthropicConnector(ConnectorBase): """Anthropic Claude API connector. Provides HTTP client access to Anthropic's Claude AI API for message @@ -213,7 +218,7 @@ class AnthropicConnector(VendorConnectorBase): ... max_tokens=1024, ... messages=[{"role": "user", "content": "Hello"}] ... ) - >>> print(response.text) + >>> print(response["content"][0]["text"]) """ API_KEY_ENV = "ANTHROPIC_API_KEY" @@ -225,8 +230,8 @@ def __init__( api_version: str = DEFAULT_API_VERSION, timeout: float = DEFAULT_TIMEOUT, logger: Logging | None = None, - **kwargs, - ): + **kwargs: Any, + ) -> None: super().__init__(api_key=api_key, logger=logger, timeout=timeout, **kwargs) # Validate API key @@ -258,15 +263,15 @@ def is_available() -> bool: return bool(os.environ.get("ANTHROPIC_API_KEY")) @staticmethod - def get_available_models() -> dict[str, str]: + def get_available_models() -> ExtendedDict: """Get dictionary of available Claude models. Returns: - Dictionary mapping model IDs to display names. + Extended dictionary mapping model IDs to display names. """ - return CLAUDE_MODELS.copy() + return extend_data(CLAUDE_MODELS.copy()) - def _handle_error(self, response) -> None: + def _handle_error(self, response: httpx.Response) -> None: """Handle API error responses. Args: @@ -277,12 +282,15 @@ def _handle_error(self, response) -> None: """ status_code = response.status_code try: - error_data = response.json() - error_type = error_data.get("error", {}).get("type", "unknown") - message = error_data.get("error", {}).get("message", response.text) + error_data = self.decode_response(response, suffix="json", as_extended=True) + raw_error = error_data.get("error", {}) if isinstance(error_data, Mapping) else {} + error = raw_error if isinstance(raw_error, Mapping) else {} + error_type = error.get("type", "unknown") + message = error.get("message", response.text) except Exception: error_type = "unknown" message = response.text + message = redact_sensitive_text(message) if status_code == 401: raise AnthropicAuthError(message, status_code=status_code, error_type=error_type) @@ -290,6 +298,57 @@ def _handle_error(self, response) -> None: raise AnthropicRateLimitError(message, status_code=status_code, error_type=error_type) raise AnthropicAPIError(message, status_code=status_code, error_type=error_type) + @staticmethod + def _model_payload(model: BaseModel) -> dict[str, Any]: + """Serialize an Anthropic model into JSON-compatible API field names.""" + return model.model_dump(mode="json") + + @staticmethod + def _unexpected_response_error(operation: str, data: Any, *, status_code: int | None = None) -> AnthropicAPIError: + """Build a redacted malformed-response error.""" + return AnthropicAPIError( + f"Unexpected Anthropic response for {operation}: {redact_sensitive_text(data)}", + status_code=status_code, + error_type="unexpected_response", + ) + + def _response_json(self, response: httpx.Response, operation: str) -> Any: + """Parse a response body or raise a redacted malformed-response error.""" + try: + return self.decode_response(response, suffix="json", as_extended=True) + except Exception: + raise self._unexpected_response_error( + operation, + response.text, + status_code=response.status_code, + ) from None + + def _parse_model_response( + self, + response: httpx.Response, + model_type: type[BaseModel], + operation: str, + ) -> dict[str, Any]: + """Validate one Anthropic model response and return a JSON payload.""" + data = self._response_json(response, operation) + try: + return self._model_payload(model_type.model_validate(to_builtin(data))) + except ValidationError: + raise self._unexpected_response_error( + operation, + data, + status_code=response.status_code, + ) from None + + @staticmethod + def _message_text(message: Mapping[str, Any]) -> str: + """Extract concatenated text blocks from an extended message payload.""" + return "".join( + str(block.get("text", "")) + for block in message.get("content", []) + if block.get("type") == "text" and block.get("text") + ) + # ========================================================================= # Message Operations # ========================================================================= @@ -307,7 +366,7 @@ def create_message( tools: list[dict[str, Any]] | None = None, tool_choice: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None, - ) -> Message: + ) -> ExtendedDict: """Create a message using Claude. Args: @@ -324,7 +383,7 @@ def create_message( metadata: Optional metadata for the request. Returns: - Message object with response. + Message response payload. Raises: AnthropicError: If the API request fails. @@ -354,12 +413,12 @@ def create_message( if metadata: body["metadata"] = metadata - response = self.post("/v1/messages", json=body) + response = self.post("/v1/messages", json=to_builtin(body)) if not response.is_success: self._handle_error(response) - return Message.model_validate(response.json()) + return self.extend_result(self._parse_model_response(response, Message, "create_message")) def count_tokens( self, @@ -394,23 +453,29 @@ def count_tokens( if tools: body["tools"] = tools - response = self.post("/v1/messages/count_tokens", json=body) + response = self.post("/v1/messages/count_tokens", json=to_builtin(body)) if not response.is_success: self._handle_error(response) - data = response.json() - return data.get("input_tokens", 0) + data = self._response_json(response, "count_tokens") + if not isinstance(data, Mapping) or not isinstance(data.get("input_tokens"), int): + raise self._unexpected_response_error( + "count_tokens", + data, + status_code=response.status_code, + ) + return data["input_tokens"] # ========================================================================= # Model Operations # ========================================================================= - def list_models(self) -> list[Model]: + def list_models(self) -> ExtendedList[ExtendedDict]: """List available models from the API. Returns: - List of Model objects. + List of model payload dictionaries. Raises: AnthropicError: If the API request fails. @@ -422,18 +487,33 @@ def list_models(self) -> list[Model]: if not response.is_success: self._handle_error(response) - data = response.json() - models_data = data.get("data", []) - return [Model.model_validate(m) for m in models_data] + data = self._response_json(response, "list_models") + models_data = data.get("data") if isinstance(data, Mapping) else None + if not isinstance(models_data, (list, ExtendedList)): + raise self._unexpected_response_error( + "list_models", + data, + status_code=response.status_code, + ) - def get_model(self, model_id: str) -> Model: + try: + parsed_models = [self._model_payload(Model.model_validate(to_builtin(model_data))) for model_data in models_data] + except ValidationError: + raise self._unexpected_response_error( + "list_models", + data, + status_code=response.status_code, + ) from None + return self.extend_result(parsed_models) + + def get_model(self, model_id: str) -> ExtendedDict: """Get information about a specific model. Args: model_id: Model identifier. Returns: - Model object with details. + Model payload dictionary with details. Raises: AnthropicError: If the API request fails. @@ -445,7 +525,7 @@ def get_model(self, model_id: str) -> Model: if not response.is_success: self._handle_error(response) - return Model.model_validate(response.json()) + return self.extend_result(self._parse_model_response(response, Model, "get_model")) # ========================================================================= # Agent Execution (Sandbox Mode) @@ -478,12 +558,12 @@ def execute_agent_task( Note: This is a simplified synchronous implementation. For production agent workflows with tools and multi-turn conversations, consider - using LangChain/LangGraph which will be available in the - extended_data.connectors.ai sub-package. + using an external workflow runner such as LangChain or LangGraph + with the tool builders in extended_data.connectors.ai_tools. """ import time - self.logger.info(f"Executing agent task: {task[:100]}...") + self.logger.info(f"Executing agent task with {len(task)} characters") start_time = time.time() default_system = """You are a helpful AI assistant that executes coding tasks. @@ -502,11 +582,12 @@ def execute_agent_task( ) duration = time.time() - start_time - total_tokens = response.usage.input_tokens + response.usage.output_tokens + usage = response.get("usage", {}) + total_tokens = int(usage.get("input_tokens", 0)) + int(usage.get("output_tokens", 0)) return AgentExecutionResult( success=True, - output=response.text, + output=self._message_text(response), duration_seconds=duration, tokens_used=total_tokens, ) @@ -516,7 +597,7 @@ def execute_agent_task( return AgentExecutionResult( success=False, output="", - error=str(e), + error=redact_sensitive_text(e), duration_seconds=duration, ) @@ -551,4 +632,4 @@ def get_recommended_model(self, use_case: str = "general") -> str: "fast": "claude-haiku-4-5-20251001", # Claude Haiku 4.5 - fastest "powerful": "claude-opus-4-5-20251101", # Claude Opus 4.5 - most capable } - return recommendations.get(use_case, recommendations["general"]) + return self.extend_result(recommendations.get(use_case, recommendations["general"])) diff --git a/src/extended_data/connectors/anthropic/tools.py b/src/extended_data/connectors/anthropic/tools.py index 1c10df1..5240e12 100644 --- a/src/extended_data/connectors/anthropic/tools.py +++ b/src/extended_data/connectors/anthropic/tools.py @@ -6,10 +6,23 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any from pydantic import BaseModel, Field +from extended_data.connectors.ai_tools import raise_unknown_tool_framework +from extended_data.containers import ExtendedDict, ExtendedList, extend_data + + +def _message_text(message: Mapping[str, Any]) -> str: + """Extract concatenated text blocks from a message payload.""" + return "".join( + str(block.get("text", "")) + for block in message.get("content", []) + if block.get("type") == "text" and block.get("text") + ) + class CreateMessageSchema(BaseModel): """Pydantic schema for the anthropic_create_message tool.""" @@ -29,7 +42,7 @@ def anthropic_create_message( prompt: str, max_tokens: int = 1024, system: str | None = None, -) -> dict[str, Any]: +) -> ExtendedDict: """Create a message using Anthropic Claude. Args: @@ -51,18 +64,20 @@ def anthropic_create_message( system=system, ) - return { - "id": response.id, - "text": response.text, - "model": response.model, - "usage": { - "input_tokens": response.usage.input_tokens, - "output_tokens": response.usage.output_tokens, - }, - } + return extend_data( + { + "id": response.get("id", ""), + "text": _message_text(response), + "model": response.get("model", ""), + "usage": { + "input_tokens": response.get("usage", {}).get("input_tokens", 0), + "output_tokens": response.get("usage", {}).get("output_tokens", 0), + }, + } + ) -def anthropic_list_models() -> list[dict[str, Any]]: +def anthropic_list_models() -> ExtendedList[ExtendedDict]: """List available Anthropic Claude models. Returns: @@ -73,7 +88,7 @@ def anthropic_list_models() -> list[dict[str, Any]]: connector = AnthropicConnector() models = connector.list_models() - return [{"id": m.id, "display_name": m.display_name} for m in models] + return extend_data([{"id": m.get("id", ""), "display_name": m.get("display_name", "")} for m in models]) TOOL_DEFINITIONS = [ @@ -94,30 +109,16 @@ def anthropic_list_models() -> list[dict[str, Any]]: def get_langchain_tools() -> list[Any]: """Get all Anthropic tools as LangChain StructuredTools.""" - try: - from langchain_core.tools import StructuredTool - except ImportError as e: - msg = "langchain-core is required for LangChain tools." - raise ImportError(msg) from e - - return [ - StructuredTool.from_function( - func=defn["func"], - name=defn["name"], - description=defn["description"], - args_schema=defn.get("schema") or defn.get("args_schema"), - ) - for defn in TOOL_DEFINITIONS - ] + from extended_data.connectors.ai_tools import build_langchain_tools + + return build_langchain_tools(TOOL_DEFINITIONS) def get_crewai_tools() -> list[Any]: """Get all Anthropic tools as CrewAI tools.""" - try: - from crewai.tools import tool as crewai_tool - except ImportError as e: - msg = "crewai is required for CrewAI tools." - raise ImportError(msg) from e + from extended_data.connectors._optional import get_crewai_tool_decorator + + crewai_tool = get_crewai_tool_decorator() tools = [] for defn in TOOL_DEFINITIONS: @@ -151,10 +152,10 @@ def get_tools(framework: str = "auto") -> list[Any]: return get_langchain_tools() if framework == "crewai": return get_crewai_tools() - if framework in ("strands", "functions"): + if framework == "strands": return get_strands_tools() - raise ValueError(f"Unknown framework: {framework}") + return raise_unknown_tool_framework(framework) __all__ = [ diff --git a/src/extended_data/connectors/aws/__init__.py b/src/extended_data/connectors/aws/__init__.py index 71499dc..eb81e78 100644 --- a/src/extended_data/connectors/aws/__init__.py +++ b/src/extended_data/connectors/aws/__init__.py @@ -4,7 +4,7 @@ - organizations: AWS Organizations and Control Tower account management - sso: IAM Identity Center (SSO) operations - s3: S3 bucket and object operations -- secrets: Secrets Manager operations (in base connector) +- secrets: Secrets Manager operations - ecs: ECS cluster and service operations Usage: @@ -16,50 +16,77 @@ from __future__ import annotations +from collections.abc import Mapping, Sequence from typing import TYPE_CHECKING, Any -import boto3 +from extended_data.connectors._optional import require_extra +from extended_data.connectors.aws._diagnostics import aws_operation_error, safe_aws_ref, safe_aws_text +from extended_data.connectors.aws.organizations import AWSOrganizationsMixin +from extended_data.connectors.aws.s3 import AWSS3Mixin +from extended_data.connectors.aws.sso import AWSSSOmixin +from extended_data.connectors.base import ConnectorBase +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString, to_builtin +from extended_data.logging import Logging +from extended_data.primitives import is_nothing -from boto3.resources.base import ServiceResource -from botocore.config import Config -from botocore.exceptions import ClientError -from extended_data import is_nothing -from extended_data.connectors.base import VendorConnectorBase -from extended_data.logging import Logging +AWSSecretValue = str | ExtendedString | Mapping[str, Any] | None if TYPE_CHECKING: - pass + import boto3 + + from boto3.resources.base import ServiceResource + from botocore.config import Config + from botocore.exceptions import ClientError +else: + boto3 = None + Config = None + ServiceResource = Any + + class ClientError(Exception): + """Fallback exception used until botocore is imported.""" + + +def _load_aws_sdk() -> Any: + """Load boto3/botocore lazily so tool metadata can import without the aws extra.""" + global ClientError, Config, ServiceResource, boto3 + if boto3 is None: + boto3 = require_extra("boto3", "aws") + Config = require_extra("botocore.config", "aws").Config + ClientError = require_extra("botocore.exceptions", "aws").ClientError + ServiceResource = require_extra("boto3.resources.base", "aws").ServiceResource + return boto3 -class AWSConnector(VendorConnectorBase): - """AWS connector for boto3 client and resource management. - This is the base connector class providing: +class AWSConnector(AWSOrganizationsMixin, AWSSSOmixin, AWSS3Mixin, ConnectorBase): + """AWS connector for boto3 client, resource, and external data operations. + + This first-class connector provides: - Session management and role assumption - Client/resource creation with retry configuration - Secrets Manager operations - - Higher-level operations are provided via mixin classes from submodules. + - Organizations, IAM Identity Center, and S3 operations """ def __init__( self, execution_role_arn: str | None = None, logger: Logging | None = None, - **kwargs, - ): + **kwargs: Any, + ) -> None: super().__init__(logger=logger, **kwargs) + self._boto3 = _load_aws_sdk() self.execution_role_arn = execution_role_arn - self.aws_sessions: dict[str, dict[str, boto3.Session]] = {} - self.default_aws_session = boto3.Session() + self.aws_sessions: dict[str, dict[str, Any]] = {} + self.default_aws_session = self._boto3.Session() # ========================================================================= # Session Management # ========================================================================= - def assume_role(self, execution_role_arn: str, role_session_name: str) -> boto3.Session: + def assume_role(self, execution_role_arn: str, role_session_name: str) -> Any: """Assume an AWS IAM role and return a boto3 Session. Args: @@ -72,27 +99,29 @@ def assume_role(self, execution_role_arn: str, role_session_name: str) -> boto3. Raises: RuntimeError: If role assumption fails. """ - self.logger.info(f"Attempting to assume role: {execution_role_arn}") + safe_role_arn = safe_aws_ref(execution_role_arn) + self.logger.info(f"Attempting to assume role: {safe_role_arn}") sts_client = self.default_aws_session.client("sts") try: response = sts_client.assume_role(RoleArn=execution_role_arn, RoleSessionName=role_session_name) credentials = response["Credentials"] - self.logger.info(f"Successfully assumed role: {execution_role_arn}") - return boto3.Session( + self.logger.info(f"Successfully assumed role: {safe_role_arn}") + return self._boto3.Session( aws_access_key_id=credentials["AccessKeyId"], aws_secret_access_key=credentials["SecretAccessKey"], aws_session_token=credentials["SessionToken"], ) except ClientError as e: - self.logger.error(f"Failed to assume role: {execution_role_arn}", exc_info=True) - raise RuntimeError(f"Failed to assume role {execution_role_arn}") from e + error_message = aws_operation_error("Failed to assume role", e, execution_role_arn) + self.logger.error(error_message) # noqa: TRY400 - traceback can expose raw provider diagnostics. + raise RuntimeError(error_message) from None def get_aws_session( self, execution_role_arn: str | None = None, role_session_name: str | None = None, - ) -> boto3.Session: + ) -> Any: """Get a boto3 Session, optionally assuming a role. Args: @@ -123,7 +152,7 @@ def get_aws_session( # ========================================================================= @staticmethod - def create_standard_retry_config(max_attempts: int = 5) -> Config: + def create_standard_retry_config(max_attempts: int = 5) -> Any: """Create a standard retry configuration. Args: @@ -132,6 +161,7 @@ def create_standard_retry_config(max_attempts: int = 5) -> Config: Returns: A botocore Config with retry settings. """ + _load_aws_sdk() return Config(retries={"max_attempts": max_attempts, "mode": "standard"}) def get_aws_client( @@ -139,9 +169,9 @@ def get_aws_client( client_name: str, execution_role_arn: str | None = None, role_session_name: str | None = None, - config: Config | None = None, - **client_args, - ) -> boto3.client: + config: Any | None = None, + **client_args: Any, + ) -> Any: """Get a boto3 client for the specified service. Args: @@ -164,9 +194,9 @@ def get_aws_resource( service_name: str, execution_role_arn: str | None = None, role_session_name: str | None = None, - config: Config | None = None, - **resource_args, - ) -> ServiceResource: + config: Any | None = None, + **resource_args: Any, + ) -> Any: """Get a boto3 resource for the specified service. Args: @@ -189,14 +219,20 @@ def get_aws_resource( try: return session.resource(service_name, config=config, **resource_args) except ClientError as e: - self.logger.error(f"Failed to create resource for service: {service_name}", exc_info=True) - raise RuntimeError(f"Failed to create resource for service {service_name}") from e + error_message = aws_operation_error( + f"Failed to create resource for service {service_name}", + e, + execution_role_arn, + role_session_name, + ) + self.logger.error(error_message) # noqa: TRY400 - traceback can expose raw provider diagnostics. + raise RuntimeError(error_message) from None # ========================================================================= # Identity Operations # ========================================================================= - def get_caller_account_id(self) -> str: + def get_caller_account_id(self) -> ExtendedString: """Get the AWS account ID of the caller. Returns: @@ -204,7 +240,7 @@ def get_caller_account_id(self) -> str: """ sts = self.get_aws_client("sts") identity = sts.get_caller_identity() - return identity["Account"] + return self.extend_result(identity["Account"]) # ========================================================================= # Secrets Manager Operations @@ -215,8 +251,8 @@ def get_secret( secret_id: str, execution_role_arn: str | None = None, role_session_name: str | None = None, - secretsmanager: boto3.client | None = None, - ) -> str | None: + secretsmanager: Any | None = None, + ) -> ExtendedString | None: """Get a single secret value from AWS Secrets Manager. Args: @@ -228,7 +264,8 @@ def get_secret( Returns: The secret value as a string, or None if not found. """ - self.logger.debug(f"Getting AWS secret: {secret_id}") + safe_secret_id = safe_aws_text(secret_id, secret_id) + self.logger.debug(f"Getting AWS secret: {safe_secret_id}") if secretsmanager is None: secretsmanager = self.get_aws_client( @@ -239,30 +276,29 @@ def get_secret( try: response = secretsmanager.get_secret_value(SecretId=secret_id) - self.logger.debug(f"Successfully retrieved secret: {secret_id}") + self.logger.debug(f"Successfully retrieved secret: {safe_secret_id}") except ClientError as e: error_code = e.response.get("Error", {}).get("Code", "") if error_code == "ResourceNotFoundException": - self.logger.warning(f"Secret not found: {secret_id}") + self.logger.warning(f"Secret not found: {safe_secret_id}") return None - self.logger.exception(f"Failed to get secret {secret_id}: {e}") - raise ValueError(f"Failed to get secret for ID '{secret_id}'") from e + error_message = aws_operation_error("Failed to get secret", e, secret_id) + self.logger.error(error_message) # noqa: TRY400 - traceback can expose raw provider diagnostics. + raise ValueError(error_message) from None if "SecretString" in response: - return response["SecretString"] - else: - return response["SecretBinary"].decode("utf-8") + return self.extend_result(response["SecretString"]) + return self.extend_result(response["SecretBinary"].decode("utf-8")) def list_secrets( self, - filters: list[dict] | None = None, + filters: Sequence[Mapping[str, Any]] | None = None, prefix: str | None = None, get_secret_values: bool = False, skip_empty_secrets: bool = False, execution_role_arn: str | None = None, role_session_name: str | None = None, - **kwargs, - ) -> dict[str, str | dict]: + ) -> ExtendedDict: """List secrets from AWS Secrets Manager. Args: @@ -272,7 +308,6 @@ def list_secrets( skip_empty_secrets: If True, skip secrets with empty values. execution_role_arn: ARN of role to assume for cross-account access. role_session_name: Session name for assumed role. - **kwargs: Support for 'name_prefix' alias. Returns: Dict mapping secret names to ARNs or values. @@ -282,8 +317,6 @@ def list_secrets( """ self.logger.info("Listing AWS Secrets Manager secrets") - prefix = prefix or kwargs.get("name_prefix") - if prefix and (".." in prefix or "\x00" in prefix): msg = "prefix contains invalid characters" raise ValueError(msg) @@ -298,16 +331,16 @@ def list_secrets( role_session_name=role_session_name, ) - secrets: dict[str, str | dict] = {} + secrets: dict[str, AWSSecretValue] = {} paginator = secretsmanager.get_paginator("list_secrets") - effective_filters: list[dict] = [] + effective_filters: list[dict[str, Any]] = [] if filters: - effective_filters.extend(filters) + effective_filters.extend(dict(to_builtin(filter_item)) for filter_item in filters) if prefix: effective_filters.append({"Key": "name", "Values": [prefix]}) - paginate_kwargs: dict = {"IncludePlannedDeletion": False} + paginate_kwargs: dict[str, Any] = {"IncludePlannedDeletion": False} if effective_filters: paginate_kwargs["Filters"] = effective_filters @@ -332,16 +365,16 @@ def list_secrets( secrets[secret_name] = secret_arn self.logger.info(f"Retrieved {len(secrets)} secrets") - return secrets + return self.extend_result(secrets) def create_secret( self, name: str, secret_value: str, description: str = "", - tags: dict[str, str] | None = None, + tags: Mapping[str, str] | None = None, execution_role_arn: str | None = None, - ) -> dict[str, Any]: + ) -> ExtendedDict: """Create a new secret in AWS Secrets Manager.""" if not name: msg = "name is required to create a secret" @@ -350,7 +383,8 @@ def create_secret( msg = "secret_value is required to create a secret" raise ValueError(msg) - self.logger.info(f"Creating AWS secret: {name}") + safe_name = safe_aws_text(name, name) + self.logger.info(f"Creating AWS secret: {safe_name}") role_arn = execution_role_arn or self.execution_role_arn secretsmanager = self.get_aws_client( client_name="secretsmanager", @@ -361,22 +395,23 @@ def create_secret( if description: create_kwargs["Description"] = description if tags: - create_kwargs["Tags"] = [{"Key": key, "Value": value} for key, value in tags.items()] + create_kwargs["Tags"] = [{"Key": str(key), "Value": str(value)} for key, value in tags.items()] try: response = secretsmanager.create_secret(**create_kwargs) - self.logger.info(f"Created AWS secret ARN: {response.get('ARN')}") - return response + self.logger.info(f"Created AWS secret ARN: {safe_aws_text(response.get('ARN'), response.get('ARN'))}") + return self.extend_result(response) except ClientError as exc: - self.logger.error(f"Failed to create secret {name}", exc_info=True) - raise RuntimeError(f"Failed to create secret '{name}'") from exc + error_message = aws_operation_error("Failed to create secret", exc, name, secret_value) + self.logger.error(error_message) # noqa: TRY400 - traceback can expose raw provider diagnostics. + raise RuntimeError(error_message) from None def update_secret( self, secret_id: str, secret_value: str, execution_role_arn: str | None = None, - ) -> dict[str, Any]: + ) -> ExtendedDict: """Update an existing secret value.""" if not secret_id: msg = "secret_id is required to update a secret" @@ -385,7 +420,8 @@ def update_secret( msg = "secret_value is required to update a secret" raise ValueError(msg) - self.logger.info(f"Updating AWS secret: {secret_id}") + safe_secret_id = safe_aws_text(secret_id, secret_id) + self.logger.info(f"Updating AWS secret: {safe_secret_id}") role_arn = execution_role_arn or self.execution_role_arn secretsmanager = self.get_aws_client( @@ -395,11 +431,13 @@ def update_secret( try: response = secretsmanager.update_secret(SecretId=secret_id, SecretString=secret_value) - self.logger.info(f"Updated AWS secret ARN: {response.get('ARN', secret_id)}") - return response + response_arn = response.get("ARN", secret_id) + self.logger.info(f"Updated AWS secret ARN: {safe_aws_text(response_arn, response_arn)}") + return self.extend_result(response) except ClientError as exc: - self.logger.error(f"Failed to update secret {secret_id}", exc_info=True) - raise RuntimeError(f"Failed to update secret '{secret_id}'") from exc + error_message = aws_operation_error("Failed to update secret", exc, secret_id, secret_value) + self.logger.error(error_message) # noqa: TRY400 - traceback can expose raw provider diagnostics. + raise RuntimeError(error_message) from None def delete_secret( self, @@ -407,7 +445,7 @@ def delete_secret( force_delete: bool = False, recovery_window_days: int = 30, execution_role_arn: str | None = None, - ) -> dict[str, Any]: + ) -> ExtendedDict: """Delete a secret from AWS Secrets Manager.""" if not secret_id: msg = "secret_id is required to delete a secret" @@ -417,7 +455,8 @@ def delete_secret( msg = "recovery_window_days must be between 7 and 30 when not forcing deletion" raise ValueError(msg) - self.logger.info(f"Deleting AWS secret: {secret_id}") + safe_secret_id = safe_aws_text(secret_id, secret_id) + self.logger.info(f"Deleting AWS secret: {safe_secret_id}") role_arn = execution_role_arn or self.execution_role_arn secretsmanager = self.get_aws_client( @@ -433,11 +472,13 @@ def delete_secret( try: response = secretsmanager.delete_secret(**delete_kwargs) - self.logger.info(f"Delete secret request submitted for: {response.get('ARN', secret_id)}") - return response + response_arn = response.get("ARN", secret_id) + self.logger.info(f"Delete secret request submitted for: {safe_aws_text(response_arn, response_arn)}") + return self.extend_result(response) except ClientError as exc: - self.logger.error(f"Failed to delete secret {secret_id}", exc_info=True) - raise RuntimeError(f"Failed to delete secret '{secret_id}'") from exc + error_message = aws_operation_error("Failed to delete secret", exc, secret_id) + self.logger.error(error_message) # noqa: TRY400 - traceback can expose raw provider diagnostics. + raise RuntimeError(error_message) from None def delete_secrets_matching( self, @@ -445,20 +486,18 @@ def delete_secrets_matching( force_delete: bool = False, dry_run: bool = True, execution_role_arn: str | None = None, - **kwargs, - ) -> list[str]: + ) -> ExtendedList[ExtendedString]: """Delete all secrets that match the provided name prefix.""" - prefix = prefix or kwargs.get("name_prefix") if not prefix: msg = "prefix is required to delete matching secrets" raise ValueError(msg) - self.logger.info(f"Deleting secrets matching prefix: {prefix} (dry_run={dry_run})") + safe_prefix = safe_aws_text(prefix, prefix) + self.logger.info(f"Deleting secrets matching prefix: {safe_prefix} (dry_run={dry_run})") role_arn = execution_role_arn or self.execution_role_arn - # Pass name_prefix to satisfy existing tests that mock this call secrets = self.list_secrets( - name_prefix=prefix, + prefix=prefix, execution_role_arn=role_arn, ) @@ -466,18 +505,18 @@ def delete_secrets_matching( for secret_name, value in secrets.items(): if isinstance(value, str): secret_arns.append(value) - elif isinstance(value, dict) and "ARN" in value: + elif isinstance(value, Mapping) and "ARN" in value: secret_arns.append(value["ARN"]) else: - self.logger.debug(f"Skipping secret {secret_name} due to missing ARN data") + self.logger.debug(f"Skipping secret {safe_aws_text(secret_name, secret_name)} due to missing ARN data") if not secret_arns: - self.logger.info(f"No secrets found for prefix: {prefix}") - return [] + self.logger.info(f"No secrets found for prefix: {safe_prefix}") + return self.extend_result([]) if dry_run: - self.logger.info(f"Dry run enabled; would delete {len(secret_arns)} secrets for prefix {prefix}") - return secret_arns + self.logger.info(f"Dry run enabled; would delete {len(secret_arns)} secrets for prefix {safe_prefix}") + return self.extend_result(secret_arns) deleted_arns: list[str] = [] for secret_arn in secret_arns: @@ -489,17 +528,17 @@ def delete_secrets_matching( ) deleted_arns.append(response.get("ARN", secret_arn)) - self.logger.info(f"Deleted {len(deleted_arns)} secrets for prefix {prefix}") - return deleted_arns + self.logger.info(f"Deleted {len(deleted_arns)} secrets for prefix {safe_prefix}") + return self.extend_result(deleted_arns) def copy_secrets_to_s3( self, - secrets: dict[str, str | dict], + secrets: Mapping[str, AWSSecretValue], bucket: str, key: str, execution_role_arn: str | None = None, role_session_name: str | None = None, - ) -> str: + ) -> ExtendedString: """Copy secrets dictionary to S3 as JSON. Args: @@ -514,7 +553,7 @@ def copy_secrets_to_s3( """ import json as json_module - self.logger.info(f"Copying {len(secrets)} secrets to s3://{bucket}/{key}") + self.logger.info(f"Copying {len(secrets)} secrets to S3") s3_client = self.get_aws_client( client_name="s3", @@ -522,7 +561,7 @@ def copy_secrets_to_s3( role_session_name=role_session_name, ) - body = json_module.dumps(secrets) + body = json_module.dumps(to_builtin(secrets)) s3_client.put_object( Bucket=bucket, Key=key, @@ -531,69 +570,57 @@ def copy_secrets_to_s3( ) s3_uri = f"s3://{bucket}/{key}" - self.logger.info(f"Uploaded secrets to {s3_uri}") - return s3_uri + self.logger.info("Uploaded secrets to S3") + return self.extend_result(s3_uri) - @staticmethod - def load_vendors_from_asm(prefix: str = "/vendors/") -> dict[str, str]: - """Load vendor secrets from AWS Secrets Manager. - - This is used in Lambda environments where vendor credentials are stored - in ASM under a common prefix (e.g., /vendors/). + def load_secrets_by_prefix( + self, + prefix: str, + *, + strip_prefix: bool = True, + uppercase_keys: bool = False, + skip_empty_secrets: bool = True, + execution_role_arn: str | None = None, + role_session_name: str | None = None, + ) -> ExtendedDict: + """Load AWS Secrets Manager values into a mapping keyed by secret name. Args: - prefix: The prefix path for vendor secrets (default: /vendors/) + prefix: AWS Secrets Manager name prefix to load. + strip_prefix: Remove the prefix from returned mapping keys. + uppercase_keys: Uppercase returned mapping keys for env-style use. + skip_empty_secrets: Skip missing or empty secret values. + execution_role_arn: ARN of role to assume for cross-account access. + role_session_name: Session name for assumed role. Returns: - Dictionary mapping secret keys (with prefix removed) to their values. + Mapping of transformed secret names to secret values. """ - import os - - vendors: dict[str, str] = {} - prefix = os.getenv("TM_VENDORS_PREFIX", prefix) - - try: - session = boto3.Session() - secretsmanager = session.client("secretsmanager") - - # List secrets with the prefix - paginator = secretsmanager.get_paginator("list_secrets") - for page in paginator.paginate(Filters=[{"Key": "name", "Values": [prefix]}]): - for secret in page.get("SecretList", []): - secret_name = secret["Name"] - if secret_name.startswith(prefix): - try: - response = secretsmanager.get_secret_value(SecretId=secret_name) - secret_value = response.get("SecretString", "") - # Remove prefix from key name - key = secret_name.removeprefix(prefix).upper() - vendors[key] = secret_value - except ClientError: - # Skip secrets we can't read - pass - except ClientError: - # Return empty dict if we can't access Secrets Manager - pass - - return vendors - - -# Import submodule operations to make them available -from extended_data.connectors.aws.codedeploy import create_codedeploy_deployment, get_aws_codedeploy_deployments -from extended_data.connectors.aws.organizations import AWSOrganizationsMixin -from extended_data.connectors.aws.s3 import AWSS3Mixin -from extended_data.connectors.aws.sso import AWSSSOmixin + if not prefix: + msg = "prefix is required to load secrets" + raise ValueError(msg) + secrets = self.list_secrets( + prefix=prefix, + get_secret_values=True, + skip_empty_secrets=skip_empty_secrets, + execution_role_arn=execution_role_arn, + role_session_name=role_session_name, + ) -class AWSConnectorFull(AWSConnector, AWSOrganizationsMixin, AWSSSOmixin, AWSS3Mixin): - """Full AWS connector with all operations. + loaded: dict[str, AWSSecretValue] = {} + for secret_name, secret_value in secrets.items(): + key = str(secret_name) + if strip_prefix and key.startswith(prefix): + key = key.removeprefix(prefix) + if uppercase_keys: + key = key.upper() + loaded[key] = secret_value - This class combines the base AWSConnector with all operation mixins. - Use this for full functionality, or use AWSConnector directly and - import specific mixins as needed. - """ + return self.extend_result(loaded) +from extended_data.connectors.aws.codedeploy import create_codedeploy_deployment, get_aws_codedeploy_deployments from extended_data.connectors.aws.tools import ( get_crewai_tools, get_langchain_tools, @@ -605,7 +632,6 @@ class AWSConnectorFull(AWSConnector, AWSOrganizationsMixin, AWSSSOmixin, AWSS3Mi __all__ = [ # Core connector classes "AWSConnector", - "AWSConnectorFull", "AWSOrganizationsMixin", "AWSS3Mixin", "AWSSSOmixin", diff --git a/src/extended_data/connectors/aws/_diagnostics.py b/src/extended_data/connectors/aws/_diagnostics.py new file mode 100644 index 0000000..630744d --- /dev/null +++ b/src/extended_data/connectors/aws/_diagnostics.py @@ -0,0 +1,44 @@ +"""AWS diagnostic redaction helpers.""" + +from __future__ import annotations + +import re + +from collections.abc import Iterable, Mapping +from typing import Any + +from extended_data.primitives.redaction import redact_sensitive_text + + +AWS_ACCOUNT_ID_RE = re.compile(r"\b\d{12}\b") + + +def _iter_diagnostic_values(values: Iterable[Any]) -> Iterable[Any]: + """Yield scalar values from nested diagnostic context.""" + for value in values: + if value is None: + continue + if isinstance(value, Mapping): + yield from _iter_diagnostic_values(value.values()) + elif isinstance(value, (str, bytes)): + yield value + elif isinstance(value, Iterable): + yield from _iter_diagnostic_values(value) + else: + yield value + + +def safe_aws_text(value: Any, *sensitive_values: Any) -> str: + """Redact secrets and caller-provided resource identifiers from AWS diagnostics.""" + redacted = redact_sensitive_text(value, values=_iter_diagnostic_values(sensitive_values)) + return AWS_ACCOUNT_ID_RE.sub("[REDACTED]", redacted) + + +def safe_aws_ref(value: Any) -> str: + """Redact a single AWS resource reference for diagnostic logs.""" + return safe_aws_text(value, value) + + +def aws_operation_error(action: str, exc: BaseException, *sensitive_values: Any) -> str: + """Build a redacted AWS operation error message.""" + return f"{action}: {safe_aws_text(exc, *sensitive_values)}" diff --git a/src/extended_data/connectors/aws/codedeploy.py b/src/extended_data/connectors/aws/codedeploy.py index 98bff86..4e069dc 100644 --- a/src/extended_data/connectors/aws/codedeploy.py +++ b/src/extended_data/connectors/aws/codedeploy.py @@ -1,24 +1,41 @@ """AWS CodeDeploy helpers for extended-data. -This module centralizes the CodeDeploy helper functions that previously -lived inside terraform-modules so Terraform stacks and standalone Python -workloads can rely on the same implementation. +This module centralizes CodeDeploy helper functions so infrastructure stacks +and standalone Python workloads can rely on the same implementation. """ from __future__ import annotations -from collections.abc import Iterable, Sequence +from collections.abc import Iterable, Mapping, Sequence from datetime import datetime, timezone -from typing import Any - -from botocore.client import BaseClient -from botocore.config import Config -from botocore.exceptions import ClientError, WaiterError +from typing import TYPE_CHECKING, Any from extended_data.connectors.aws import AWSConnector +from extended_data.connectors.aws._diagnostics import aws_operation_error, safe_aws_ref, safe_aws_text +from extended_data.containers import ExtendedDict, extend_data, to_builtin from extended_data.logging import Logging +if TYPE_CHECKING: + from botocore.client import BaseClient + from botocore.config import Config + from botocore.exceptions import ClientError, WaiterError +else: + BaseClient = Any + Config = Any + + try: + from botocore.exceptions import ClientError, WaiterError + except ImportError: + + class ClientError(Exception): + """Fallback exception used until botocore is imported.""" + + + class WaiterError(Exception): + """Fallback exception used until botocore is imported.""" + + _BATCH_GET_LIMIT = 25 _VALID_FILE_BEHAVIORS = {"DISALLOW", "OVERWRITE", "RETAIN"} _DEPLOYMENT_STATUS_MAP = { @@ -122,8 +139,12 @@ def _safe_get_deployment( ) -> dict[str, Any] | None: try: response = codedeploy_client.get_deployment(deploymentId=deployment_id) - except ClientError: - logger.warning("Unable to fetch CodeDeploy deployment details for %s", deployment_id, exc_info=True) + except ClientError as exc: + logger.warning( + "Unable to fetch CodeDeploy deployment details for %s: %s", + safe_aws_ref(deployment_id), + safe_aws_text(exc, deployment_id), + ) return None return response.get("deploymentInfo") @@ -135,7 +156,7 @@ def get_aws_codedeploy_deployments( statuses: Sequence[str] | None = None, created_after: datetime | str | float | None = None, created_before: datetime | str | float | None = None, - tag_filters: Sequence[dict[str, Any]] | None = None, + tag_filters: Sequence[Mapping[str, Any]] | None = None, include_details: bool = True, limit: int | None = None, next_token: str | None = None, @@ -147,7 +168,7 @@ def get_aws_codedeploy_deployments( region_name: str | None = None, config: Config | None = None, logging_adapter: Logging | None = None, -) -> dict[str, Any]: +) -> ExtendedDict: """List CodeDeploy deployments with optional detail hydration. Returns a dictionary with the deployment identifiers, optional deployment @@ -184,7 +205,7 @@ def get_aws_codedeploy_deployments( if end: params["createTimeRange"]["end"] = end if tag_filters: - params["tagFilters"] = list(tag_filters) + params["tagFilters"] = [dict(to_builtin(tag_filter)) for tag_filter in tag_filters] deployment_ids: list[str] = [] pages = 0 @@ -218,9 +239,17 @@ def get_aws_codedeploy_deployments( final_token = token break except ClientError as exc: - logger.error("Failed to list CodeDeploy deployments", exc_info=True) - msg = "Failed to list AWS CodeDeploy deployments" - raise RuntimeError(msg) from exc + msg = aws_operation_error( + "Failed to list AWS CodeDeploy deployments", + exc, + application_name, + deployment_group_name, + deployment_config_name, + next_token, + tag_filters, + ) + logger.error(msg) # noqa: TRY400 - traceback can expose raw provider diagnostics. + raise RuntimeError(msg) from None deployment_infos: list[dict[str, Any]] | None = None if include_details and deployment_ids: @@ -232,29 +261,28 @@ def get_aws_codedeploy_deployments( if deployment_id in items: deployment_infos.append(items[deployment_id]) - logger.info( - "Fetched %s CodeDeploy deployments%s", - len(deployment_ids), - f" (next token: {final_token})" if final_token else "", - ) + safe_token_detail = f" (next token: {safe_aws_ref(final_token)})" if final_token else "" + logger.info("Fetched %s CodeDeploy deployments%s", len(deployment_ids), safe_token_detail) _ = connector # appease linters when we instantiate a connector internally - return { - "deployment_ids": deployment_ids, - "deployments": deployment_infos, - "next_token": final_token, - "pages": pages, - } + return extend_data( + { + "deployment_ids": deployment_ids, + "deployments": deployment_infos, + "next_token": final_token, + "pages": pages, + } + ) def create_codedeploy_deployment( application_name: str, deployment_group_name: str, - revision: dict[str, Any], + revision: Mapping[str, Any], description: str | None = None, ignore_application_stop_failures: bool | None = None, file_exists_behavior: str | None = None, - auto_rollback_configuration: dict[str, Any] | None = None, + auto_rollback_configuration: Mapping[str, Any] | None = None, update_outdated_instances_only: bool | None = None, wait: bool = False, waiter_delay: int = 15, @@ -268,7 +296,7 @@ def create_codedeploy_deployment( config: Config | None = None, logging_adapter: Logging | None = None, **additional_params: Any, -) -> dict[str, Any]: +) -> ExtendedDict: """Create a CodeDeploy deployment and optionally wait for completion.""" if not revision: msg = "The CodeDeploy revision payload is required." @@ -297,7 +325,7 @@ def create_codedeploy_deployment( request: dict[str, Any] = { "applicationName": application_name, "deploymentGroupName": deployment_group_name, - "revision": revision, + "revision": dict(to_builtin(revision)), } if description: request["description"] = description @@ -306,24 +334,36 @@ def create_codedeploy_deployment( if file_exists_behavior: request["fileExistsBehavior"] = file_exists_behavior if auto_rollback_configuration: - request["autoRollbackConfiguration"] = auto_rollback_configuration + request["autoRollbackConfiguration"] = dict(to_builtin(auto_rollback_configuration)) if update_outdated_instances_only is not None: request["updateOutdatedInstancesOnly"] = update_outdated_instances_only - request.update(additional_params) + request.update(to_builtin(additional_params)) try: response = client.create_deployment(**request) except ClientError as exc: - logger.error("Failed to create CodeDeploy deployment", exc_info=True) - msg = "Failed to create AWS CodeDeploy deployment" - raise RuntimeError(msg) from exc + msg = aws_operation_error( + "Failed to create AWS CodeDeploy deployment", + exc, + application_name, + deployment_group_name, + revision, + description, + ) + logger.error(msg) # noqa: TRY400 - traceback can expose raw provider diagnostics. + raise RuntimeError(msg) from None deployment_id = response.get("deploymentId") if not deployment_id: msg = "CodeDeploy did not return a deploymentId." raise RuntimeError(msg) - logger.info("Created CodeDeploy deployment %s for %s/%s", deployment_id, application_name, deployment_group_name) + logger.info( + "Created CodeDeploy deployment %s for %s/%s", + safe_aws_ref(deployment_id), + safe_aws_ref(application_name), + safe_aws_ref(deployment_group_name), + ) deployment_info: dict[str, Any] | None = None if wait: @@ -333,18 +373,22 @@ def create_codedeploy_deployment( deploymentId=deployment_id, WaiterConfig={"Delay": waiter_delay, "MaxAttempts": waiter_max_attempts}, ) - except WaiterError as exc: + except WaiterError: deployment_info = _safe_get_deployment(client, deployment_id, logger) status = deployment_info.get("status") if deployment_info else "unknown" - msg = f"Deployment {deployment_id} did not reach a successful state (status={status})." - raise RuntimeError(msg) from exc + safe_deployment_id = safe_aws_ref(deployment_id) + safe_status = safe_aws_text(status) + msg = f"Deployment {safe_deployment_id} did not reach a successful state (status={safe_status})." + raise RuntimeError(msg) from None deployment_info = _safe_get_deployment(client, deployment_id, logger) elif include_details: deployment_info = _safe_get_deployment(client, deployment_id, logger) _ = connector - return { - "deployment_id": deployment_id, - "status": deployment_info.get("status") if deployment_info else None, - "deployment_info": deployment_info, - } + return extend_data( + { + "deployment_id": deployment_id, + "status": deployment_info.get("status") if deployment_info else None, + "deployment_info": deployment_info, + } + ) diff --git a/src/extended_data/connectors/aws/organizations.py b/src/extended_data/connectors/aws/organizations.py index 8e41c3c..66d69ba 100644 --- a/src/extended_data/connectors/aws/organizations.py +++ b/src/extended_data/connectors/aws/organizations.py @@ -9,12 +9,15 @@ import re from collections import defaultdict +from collections.abc import Iterator, Mapping, Sequence from copy import deepcopy from typing import TYPE_CHECKING, Any from deepmerge import always_merger -from extended_data import is_nothing, unhump_map +from extended_data.connectors.aws._diagnostics import safe_aws_ref, safe_aws_text +from extended_data.containers import ExtendedDict, to_builtin +from extended_data.primitives import is_nothing, unhump_map if TYPE_CHECKING: @@ -30,12 +33,29 @@ class AWSOrganizationsMixin: - execution_role_arn """ + if TYPE_CHECKING: + logger: Any + execution_role_arn: str | None + + def get_aws_client( + self, + client_name: str, + execution_role_arn: str | None = None, + role_session_name: str | None = None, + config: Any | None = None, + **client_args: Any, + ) -> Any: ... + + def get_caller_account_id(self) -> Any: ... + + def extend_result(self, value: Any) -> Any: ... + def get_organization_accounts( self, unhump_accounts: bool = True, sort_by_name: bool = False, execution_role_arn: str | None = None, - ) -> dict[str, dict[str, Any]]: + ) -> ExtendedDict: """Get all AWS accounts from AWS Organizations. Recursively traverses the organization hierarchy to get all accounts @@ -71,16 +91,17 @@ def get_organization_accounts( try: root_parent_id = roots["Roots"][0]["Id"] - except (KeyError, IndexError) as exc: - raise RuntimeError(f"Failed to find root parent ID: {roots}") from exc + except (KeyError, IndexError): + msg = f"Failed to find root parent ID: {safe_aws_text(roots, roots)}" + raise RuntimeError(msg) from None - self.logger.info(f"Root parent ID: {root_parent_id}") + self.logger.info(f"Root parent ID: {safe_aws_ref(root_parent_id)}") accounts_paginator = orgs.get_paginator("list_accounts_for_parent") ou_paginator = orgs.get_paginator("list_organizational_units_for_parent") tags_paginator = orgs.get_paginator("list_tags_for_resource") - def yield_tag_keypairs(tags: list[dict[str, str]]): + def yield_tag_keypairs(tags: list[dict[str, str]]) -> Iterator[tuple[str, str]]: for tag in tags: yield tag["Key"], tag["Value"] @@ -102,7 +123,7 @@ def get_accounts_recursive(parent_id: str) -> dict[str, dict[str, Any]]: for ou in page["OrganizationalUnits"]: ou_id = ou["Id"] ou_data = org_units.get(ou_id) - if is_nothing(ou_data): + if ou_data is None or is_nothing(ou_data): ou_data = {} for k, v in deepcopy(ou).items(): ou_data[f"Ou{k.title()}"] = v @@ -128,14 +149,14 @@ def get_accounts_recursive(parent_id: str) -> dict[str, dict[str, Any]]: aws_accounts = dict(sorted(aws_accounts.items(), key=lambda x: x[1].get(key_field, ""))) self.logger.info(f"Retrieved {len(aws_accounts)} organization accounts") - return aws_accounts + return self.extend_result(aws_accounts) def get_controltower_accounts( self, unhump_accounts: bool = True, sort_by_name: bool = False, execution_role_arn: str | None = None, - ) -> dict[str, dict[str, Any]]: + ) -> ExtendedDict: """Get all AWS accounts managed by AWS Control Tower. Retrieves accounts from the Control Tower Account Factory. @@ -184,7 +205,7 @@ def get_controltower_accounts( pass except ClientError as e: - self.logger.warning(f"Could not list Control Tower accounts: {e}") + self.logger.warning(f"Could not list Control Tower accounts: {safe_aws_text(e)}") # Apply transformations if unhump_accounts: @@ -195,7 +216,7 @@ def get_controltower_accounts( accounts = dict(sorted(accounts.items(), key=lambda x: x[1].get(key_field, ""))) self.logger.info(f"Retrieved {len(accounts)} Control Tower accounts") - return accounts + return self.extend_result(accounts) def get_accounts( self, @@ -203,7 +224,7 @@ def get_accounts( sort_by_name: bool = False, include_controltower: bool = True, execution_role_arn: str | None = None, - ) -> dict[str, dict[str, Any]]: + ) -> ExtendedDict: """Get all AWS accounts from Organizations and Control Tower. Combines accounts from AWS Organizations and Control Tower, marking @@ -221,18 +242,22 @@ def get_accounts( self.logger.info("Getting all AWS accounts") # Get organization accounts - aws_accounts = self.get_organization_accounts( - unhump_accounts=False, - sort_by_name=False, - execution_role_arn=execution_role_arn, + aws_accounts = to_builtin( + self.get_organization_accounts( + unhump_accounts=False, + sort_by_name=False, + execution_role_arn=execution_role_arn, + ) ) # Merge with Control Tower accounts if include_controltower: - controltower_accounts = self.get_controltower_accounts( - unhump_accounts=False, - sort_by_name=False, - execution_role_arn=execution_role_arn, + controltower_accounts = to_builtin( + self.get_controltower_accounts( + unhump_accounts=False, + sort_by_name=False, + execution_role_arn=execution_role_arn, + ) ) aws_accounts = always_merger.merge(aws_accounts, controltower_accounts) @@ -245,13 +270,13 @@ def get_accounts( aws_accounts = dict(sorted(aws_accounts.items(), key=lambda x: x[1].get(key_field, ""))) self.logger.info(f"Retrieved {len(aws_accounts)} total AWS accounts") - return aws_accounts + return self.extend_result(aws_accounts) def get_organization_units( self, unhump_units: bool = True, execution_role_arn: str | None = None, - ) -> dict[str, dict[str, Any]]: + ) -> ExtendedDict: """Get all organizational units from AWS Organizations. Args: @@ -275,7 +300,7 @@ def get_organization_units( ou_paginator = orgs.get_paginator("list_organizational_units_for_parent") org_units: dict[str, dict[str, Any]] = {} - def get_ous_recursive(parent_id: str, parent_path: str = ""): + def get_ous_recursive(parent_id: str, parent_path: str = "") -> None: for page in ou_paginator.paginate(ParentId=parent_id): for ou in page["OrganizationalUnits"]: ou_id = ou["Id"] @@ -290,7 +315,7 @@ def get_ous_recursive(parent_id: str, parent_path: str = ""): org_units = {k: unhump_map(v) for k, v in org_units.items()} self.logger.info(f"Retrieved {len(org_units)} organizational units") - return org_units + return self.extend_result(org_units) # ------------------------------------------------------------------ # # Internal helpers # @@ -319,7 +344,7 @@ def get_tags(resource_id: str) -> dict[str, str]: tag_map[tag["Key"]] = tag["Value"] return tag_map - def walk(parent_id: str): + def walk(parent_id: str) -> None: for page in ou_paginator.paginate(ParentId=parent_id): for ou in page["OrganizationalUnits"]: ou_id = ou["Id"] @@ -438,7 +463,7 @@ def _process_classifications(value: str) -> list[str]: def label_account( self, account_id: str, - labels: dict[str, str], + labels: Mapping[str, str], execution_role_arn: str | None = None, ) -> None: """Apply labels (tags) to an AWS account. @@ -448,7 +473,7 @@ def label_account( labels: Dictionary of label key-value pairs to apply. execution_role_arn: ARN of role to assume for cross-account access. """ - self.logger.info(f"Labeling AWS account {account_id} with {len(labels)} tags") + self.logger.info(f"Labeling AWS account {safe_aws_ref(account_id)} with {len(labels)} tags") role_arn = execution_role_arn or getattr(self, "execution_role_arn", None) orgs = self.get_aws_client( @@ -456,16 +481,16 @@ def label_account( execution_role_arn=role_arn, ) - tags = [{"Key": k, "Value": v} for k, v in labels.items()] + tags = [{"Key": str(k), "Value": str(v)} for k, v in labels.items()] orgs.tag_resource(ResourceId=account_id, Tags=tags) - self.logger.info(f"Applied {len(labels)} tags to account {account_id}") + self.logger.info(f"Applied {len(labels)} tags to account {safe_aws_ref(account_id)}") def classify_accounts( self, - accounts: dict[str, dict[str, Any]] | None = None, - classification_rules: dict[str, list[str]] | None = None, + accounts: Mapping[str, Mapping[str, Any]] | None = None, + classification_rules: Mapping[str, Sequence[str]] | None = None, execution_role_arn: str | None = None, - ) -> dict[str, dict[str, Any]]: + ) -> ExtendedDict: """Classify AWS accounts based on OU paths or tags. Default classification rules: @@ -490,6 +515,9 @@ def classify_accounts( unhump_accounts=True, execution_role_arn=execution_role_arn, ) + account_map: dict[str, dict[str, Any]] = { + account_id: dict(to_builtin(account_data)) for account_id, account_data in accounts.items() + } default_rules = { "production": ["prod", "production"], @@ -502,7 +530,7 @@ def classify_accounts( } rules = classification_rules or default_rules - for account_id, account_data in accounts.items(): + for account_id, account_data in account_map.items(): ou_name = account_data.get("ou_name", "").lower() ou_path = account_data.get("path", "").lower() if "path" in account_data else "" tags = account_data.get("tags", {}) @@ -521,26 +549,24 @@ def classify_accounts( if classification != "unclassified": break - accounts[account_id]["classification"] = classification + account_map[account_id]["classification"] = classification - self.logger.info(f"Classified {len(accounts)} accounts") - return accounts + self.logger.info(f"Classified {len(account_map)} accounts") + return self.extend_result(account_map) # --------------------------------------------------------------------- # - # Terraform-migrated helpers # + # Account labeling and organization preprocessing helpers # # --------------------------------------------------------------------- # def label_aws_accounts( self, - domains: dict[str, str], - aws_organization_units: dict[str, dict[str, Any]] | None = None, + domains: Mapping[str, str], + aws_organization_units: Mapping[str, Mapping[str, Any]] | None = None, caller_account_id: str | None = None, execution_role_arn: str | None = None, - ) -> dict[str, dict[str, Any]]: + ) -> ExtendedDict: """Return normalized metadata for every AWS account. - This mirrors the historical ``label_aws_account`` helper from terraform-modules. - Args: domains: Mapping of environment -> root domain. aws_organization_units: Optional precomputed OU metadata (with tags). @@ -556,8 +582,13 @@ def label_aws_accounts( raise ValueError(msg) role_arn = execution_role_arn or getattr(self, "execution_role_arn", None) - units_lookup = aws_organization_units or self._build_org_units_with_tags(role_arn=role_arn) - caller_account_id = caller_account_id or self.get_caller_account_id() + units_lookup = ( + {unit_id: dict(to_builtin(unit)) for unit_id, unit in aws_organization_units.items()} + if aws_organization_units is not None + else self._build_org_units_with_tags(role_arn=role_arn) + ) + domain_lookup = {str(key): str(value) for key, value in domains.items()} + caller_account_id = caller_account_id or str(self.get_caller_account_id()) organization_accounts = self.get_organization_accounts( unhump_accounts=False, @@ -579,7 +610,7 @@ def label_aws_accounts( account_data=account_data, controltower_data=controltower_data, units_lookup=units_lookup, - domains=domains, + domains=domain_lookup, caller_account_id=caller_account_id, ) @@ -592,20 +623,20 @@ def label_aws_accounts( account_data=controltower_data, controltower_data=controltower_data, units_lookup=units_lookup, - domains=domains, + domains=domain_lookup, caller_account_id=caller_account_id, ) - return labeled_accounts + return self.extend_result(labeled_accounts) def label_aws_account( self, account_id: str, - domains: dict[str, str], - aws_organization_units: dict[str, dict[str, Any]] | None = None, + domains: Mapping[str, str], + aws_organization_units: Mapping[str, Mapping[str, Any]] | None = None, caller_account_id: str | None = None, execution_role_arn: str | None = None, - ) -> dict[str, Any]: + ) -> ExtendedDict: """Return metadata for a single AWS account.""" labeled_accounts = self.label_aws_accounts( domains=domains, @@ -614,20 +645,20 @@ def label_aws_account( execution_role_arn=execution_role_arn, ) try: - return labeled_accounts[account_id] - except KeyError as exc: # pragma: no cover - defensive guard - raise KeyError(f"AWS account {account_id} not found") from exc + return self.extend_result(labeled_accounts[account_id]) + except KeyError: # pragma: no cover - defensive guard + raise KeyError(f"AWS account {safe_aws_ref(account_id)} not found") from None def classify_aws_accounts( self, - labeled_accounts: dict[str, dict[str, Any]] | None = None, + labeled_accounts: Mapping[str, Mapping[str, Any]] | None = None, suffix: str | None = None, - domains: dict[str, str] | None = None, - aws_organization_units: dict[str, dict[str, Any]] | None = None, + domains: Mapping[str, str] | None = None, + aws_organization_units: Mapping[str, Mapping[str, Any]] | None = None, caller_account_id: str | None = None, execution_role_arn: str | None = None, - ) -> dict[str, list[str]]: - """Group accounts by classification, matching terraform-modules output.""" + ) -> ExtendedDict: + """Group accounts by classification for infrastructure data consumers.""" if labeled_accounts is None: if not domains: msg = "domains mapping required when labeled_accounts is not provided" @@ -648,16 +679,16 @@ def classify_aws_accounts( continue classified_accounts[f"{classification}{suffix_value}"].append(account_key) - return dict(classified_accounts) + return self.extend_result(dict(classified_accounts)) def preprocess_aws_organization( self, - domains: dict[str, str], + domains: Mapping[str, str], suffix: str | None = None, - aws_organization_units: dict[str, dict[str, Any]] | None = None, + aws_organization_units: Mapping[str, Mapping[str, Any]] | None = None, caller_account_id: str | None = None, execution_role_arn: str | None = None, - ) -> dict[str, Any]: + ) -> ExtendedDict: """Build full organization context (accounts, units, lookups).""" role_arn = execution_role_arn or getattr(self, "execution_role_arn", None) units_lookup = aws_organization_units or self._build_org_units_with_tags(role_arn=role_arn) @@ -687,33 +718,35 @@ def preprocess_aws_organization( units_by_name = {unit["name"]: unit for unit in units_lookup.values() if unit.get("name")} - return { - "accounts": labeled_accounts, - "units": units_lookup, - "unit_classifications_by_name": { - name: unit.get("classifications", []) for name, unit in units_by_name.items() - }, - "accounts_by_classification": classification_lookup, - "accounts_by_name": accounts_by_name, - "accounts_by_email": accounts_by_email, - "accounts_by_key": accounts_by_key, - "organization": { - "root_id": root_id, - "organizational_units": units_lookup, - "account_count": len(labeled_accounts), - "ou_count": len(units_lookup), - }, - } + return self.extend_result( + { + "accounts": labeled_accounts, + "units": units_lookup, + "unit_classifications_by_name": { + name: unit.get("classifications", []) for name, unit in units_by_name.items() + }, + "accounts_by_classification": classification_lookup, + "accounts_by_name": accounts_by_name, + "accounts_by_email": accounts_by_email, + "accounts_by_key": accounts_by_key, + "organization": { + "root_id": root_id, + "organizational_units": units_lookup, + "account_count": len(labeled_accounts), + "ou_count": len(units_lookup), + }, + } + ) def preprocess_organization( self, include_tags: bool = True, include_classification: bool = True, execution_role_arn: str | None = None, - ) -> dict[str, Any]: - """Preprocess AWS Organization data for terraform consumption. + ) -> ExtendedDict: + """Preprocess AWS Organization data for infrastructure workflows. - Returns a structured dict suitable for terraform data sources. + Returns a structured dictionary suitable for downstream data sources. Args: include_tags: Include account tags. Defaults to True. @@ -760,4 +793,4 @@ def preprocess_organization( } self.logger.info(f"Preprocessed org: {len(accounts)} accounts, {len(org_units)} OUs") - return result + return self.extend_result(result) diff --git a/src/extended_data/connectors/aws/s3.py b/src/extended_data/connectors/aws/s3.py index 910e483..bf3c573 100644 --- a/src/extended_data/connectors/aws/s3.py +++ b/src/extended_data/connectors/aws/s3.py @@ -5,17 +5,32 @@ from __future__ import annotations -import json +from collections.abc import Mapping, Sequence +from typing import TYPE_CHECKING, Any, cast -from typing import TYPE_CHECKING, Any - -from botocore.exceptions import ClientError - -from extended_data import unhump_map +from extended_data.connectors.aws._diagnostics import safe_aws_ref, safe_aws_text +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString, to_builtin +from extended_data.io import wrap_raw_data_for_export +from extended_data.io.files import decode_file +from extended_data.primitives import unhump_map if TYPE_CHECKING: from boto3.resources.base import ServiceResource + from botocore.exceptions import ClientError +else: + try: + from botocore.exceptions import ClientError + except ImportError: + + class ClientError(Exception): + """Fallback exception used until botocore is imported.""" + + +def _safe_s3_uri(bucket: str, key: str | None = None) -> str: + """Return a diagnostic-safe S3 URI.""" + uri = f"s3://{bucket}" if key is None else f"s3://{bucket}/{key}" + return safe_aws_text(uri, bucket, key) class AWSS3Mixin: @@ -28,11 +43,35 @@ class AWSS3Mixin: - execution_role_arn """ + if TYPE_CHECKING: + logger: Any + execution_role_arn: str | None + + def get_aws_client( + self, + client_name: str, + execution_role_arn: str | None = None, + role_session_name: str | None = None, + config: Any | None = None, + **client_args: Any, + ) -> Any: ... + + def get_aws_resource( + self, + service_name: str, + execution_role_arn: str | None = None, + role_session_name: str | None = None, + config: Any | None = None, + **resource_args: Any, + ) -> ServiceResource: ... + + def extend_result(self, value: Any) -> Any: ... + def list_s3_buckets( self, unhump_buckets: bool = True, execution_role_arn: str | None = None, - ) -> dict[str, dict[str, Any]]: + ) -> ExtendedDict: """List all S3 buckets. Args: @@ -61,13 +100,13 @@ def list_s3_buckets( buckets = {k: unhump_map(v) for k, v in buckets.items()} self.logger.info(f"Retrieved {len(buckets)} buckets") - return buckets + return self.extend_result(buckets) def get_bucket_location( self, bucket_name: str, execution_role_arn: str | None = None, - ) -> str: + ) -> ExtendedString: """Get the region of an S3 bucket. Args: @@ -77,7 +116,8 @@ def get_bucket_location( Returns: The AWS region where the bucket is located. """ - self.logger.debug(f"Getting location for bucket: {bucket_name}") + safe_bucket = safe_aws_ref(bucket_name) + self.logger.debug(f"Getting location for bucket: {safe_bucket}") role_arn = execution_role_arn or getattr(self, "execution_role_arn", None) s3 = self.get_aws_client( @@ -86,7 +126,7 @@ def get_bucket_location( ) response = s3.get_bucket_location(Bucket=bucket_name) - return response.get("LocationConstraint") or "us-east-1" + return self.extend_result(response.get("LocationConstraint") or "us-east-1") def get_object( self, @@ -94,7 +134,7 @@ def get_object( key: str, decode: bool = True, execution_role_arn: str | None = None, - ) -> str | bytes | None: + ) -> ExtendedString | bytes | None: """Get an object from S3. Args: @@ -106,7 +146,8 @@ def get_object( Returns: The object contents, or None if not found. """ - self.logger.debug(f"Getting S3 object: s3://{bucket}/{key}") + safe_uri = _safe_s3_uri(bucket, key) + self.logger.debug(f"Getting S3 object: {safe_uri}") role_arn = execution_role_arn or getattr(self, "execution_role_arn", None) s3 = self.get_aws_client( @@ -119,11 +160,11 @@ def get_object( body = response["Body"].read() if decode: - return body.decode("utf-8") + return self.extend_result(body.decode("utf-8")) return body except ClientError as e: if e.response.get("Error", {}).get("Code") == "NoSuchKey": - self.logger.warning(f"S3 object not found: s3://{bucket}/{key}") + self.logger.warning(f"S3 object not found: {safe_uri}") return None raise @@ -132,7 +173,7 @@ def get_json_object( bucket: str, key: str, execution_role_arn: str | None = None, - ) -> dict[str, Any] | list | None: + ) -> ExtendedDict | ExtendedList[Any] | None: """Get a JSON object from S3. Args: @@ -146,14 +187,15 @@ def get_json_object( content = self.get_object( bucket=bucket, key=key, - decode=True, + decode=False, execution_role_arn=execution_role_arn, ) if content is None: return None - return json.loads(content) + file_data = str(content) if isinstance(content, ExtendedString) else content + return cast(ExtendedDict | ExtendedList[Any], decode_file(file_data, suffix="json", as_extended=True)) def put_object( self, @@ -161,9 +203,9 @@ def put_object( key: str, body: str | bytes, content_type: str | None = None, - metadata: dict[str, str] | None = None, + metadata: Mapping[str, str] | None = None, execution_role_arn: str | None = None, - ) -> dict[str, Any]: + ) -> ExtendedDict: """Put an object to S3. Args: @@ -177,7 +219,8 @@ def put_object( Returns: The S3 put_object response. """ - self.logger.debug(f"Putting S3 object: s3://{bucket}/{key}") + safe_uri = _safe_s3_uri(bucket, key) + self.logger.debug(f"Putting S3 object: {safe_uri}") role_arn = execution_role_arn or getattr(self, "execution_role_arn", None) s3 = self.get_aws_client( @@ -202,21 +245,21 @@ def put_object( put_args["ContentType"] = "text/yaml" if metadata: - put_args["Metadata"] = metadata + put_args["Metadata"] = {str(key): str(value) for key, value in metadata.items()} response = s3.put_object(**put_args) - self.logger.debug(f"Put object to s3://{bucket}/{key}") - return response + self.logger.debug(f"Put object to {safe_uri}") + return self.extend_result(response) def put_json_object( self, bucket: str, key: str, - data: dict[str, Any] | list, + data: Mapping[str, Any] | Sequence[Any], indent: int = 2, - metadata: dict[str, str] | None = None, + metadata: Mapping[str, str] | None = None, execution_role_arn: str | None = None, - ) -> dict[str, Any]: + ) -> ExtendedDict: """Put a JSON object to S3. Args: @@ -230,7 +273,7 @@ def put_json_object( Returns: The S3 put_object response. """ - body = json.dumps(data, indent=indent, default=str) + body = wrap_raw_data_for_export(data, allow_encoding="json", indent_2=bool(indent)) return self.put_object( bucket=bucket, key=key, @@ -245,7 +288,7 @@ def delete_object( bucket: str, key: str, execution_role_arn: str | None = None, - ) -> dict[str, Any]: + ) -> ExtendedDict: """Delete an object from S3. Args: @@ -256,7 +299,8 @@ def delete_object( Returns: The S3 delete_object response. """ - self.logger.debug(f"Deleting S3 object: s3://{bucket}/{key}") + safe_uri = _safe_s3_uri(bucket, key) + self.logger.debug(f"Deleting S3 object: {safe_uri}") role_arn = execution_role_arn or getattr(self, "execution_role_arn", None) s3 = self.get_aws_client( @@ -265,8 +309,8 @@ def delete_object( ) response = s3.delete_object(Bucket=bucket, Key=key) - self.logger.debug(f"Deleted object s3://{bucket}/{key}") - return response + self.logger.debug(f"Deleted object {safe_uri}") + return self.extend_result(response) def list_objects( self, @@ -276,7 +320,7 @@ def list_objects( max_keys: int | None = None, unhump_objects: bool = True, execution_role_arn: str | None = None, - ) -> list[dict[str, Any]]: + ) -> ExtendedList[ExtendedDict]: """List objects in an S3 bucket. Args: @@ -290,7 +334,8 @@ def list_objects( Returns: List of object metadata dictionaries. """ - self.logger.debug(f"Listing objects in s3://{bucket}/{prefix or ''}") + safe_uri = _safe_s3_uri(bucket, prefix or None) + self.logger.debug(f"Listing objects in {safe_uri}") role_arn = execution_role_arn or getattr(self, "execution_role_arn", None) s3 = self.get_aws_client( @@ -321,7 +366,7 @@ def list_objects( objects = [unhump_map(o) for o in objects] self.logger.debug(f"Found {len(objects)} objects") - return objects + return self.extend_result(objects) def copy_object( self, @@ -330,7 +375,7 @@ def copy_object( dest_bucket: str, dest_key: str, execution_role_arn: str | None = None, - ) -> dict[str, Any]: + ) -> ExtendedDict: """Copy an object within S3. Args: @@ -343,7 +388,9 @@ def copy_object( Returns: The S3 copy_object response. """ - self.logger.debug(f"Copying s3://{source_bucket}/{source_key} to s3://{dest_bucket}/{dest_key}") + safe_source_uri = _safe_s3_uri(source_bucket, source_key) + safe_dest_uri = _safe_s3_uri(dest_bucket, dest_key) + self.logger.debug(f"Copying {safe_source_uri} to {safe_dest_uri}") role_arn = execution_role_arn or getattr(self, "execution_role_arn", None) s3 = self.get_aws_client( @@ -356,8 +403,8 @@ def copy_object( Key=dest_key, CopySource={"Bucket": source_bucket, "Key": source_key}, ) - self.logger.debug(f"Copied object to s3://{dest_bucket}/{dest_key}") - return response + self.logger.debug(f"Copied object to {safe_dest_uri}") + return self.extend_result(response) # ========================================================================= # Bucket Features and Configuration @@ -367,7 +414,7 @@ def get_bucket_features( self, bucket_name: str, execution_role_arn: str | None = None, - ) -> dict[str, Any]: + ) -> ExtendedDict: """Get bucket configuration features (logging, versioning, lifecycle, policy). Args: @@ -377,7 +424,8 @@ def get_bucket_features( Returns: Dictionary with logging, versioning, lifecycle_rules, and policy. """ - self.logger.debug(f"Getting features for bucket: {bucket_name}") + safe_bucket = safe_aws_ref(bucket_name) + self.logger.debug(f"Getting features for bucket: {safe_bucket}") role_arn = execution_role_arn or getattr(self, "execution_role_arn", None) s3_resource: ServiceResource = self.get_aws_resource( @@ -389,8 +437,8 @@ def get_bucket_features( # Check if bucket exists if not bucket.creation_date: - self.logger.warning(f"Bucket does not exist: {bucket_name}") - return {} + self.logger.warning(f"Bucket does not exist: {safe_bucket}") + return self.extend_result({}) features: dict[str, Any] = {} @@ -426,14 +474,14 @@ def get_bucket_features( self.logger.debug("No policy for bucket") features["policy"] = None - return features + return self.extend_result(features) def find_buckets_by_name( self, name_contains: str, include_features: bool = False, execution_role_arn: str | None = None, - ) -> dict[str, dict[str, Any]]: + ) -> ExtendedDict: """Find S3 buckets with names containing a string. Args: @@ -444,7 +492,8 @@ def find_buckets_by_name( Returns: Dictionary mapping bucket names to bucket data/features. """ - self.logger.info(f"Finding S3 buckets containing: {name_contains}") + safe_search = safe_aws_ref(name_contains) + self.logger.info(f"Finding S3 buckets containing: {safe_search}") role_arn = execution_role_arn or getattr(self, "execution_role_arn", None) s3_resource: ServiceResource = self.get_aws_resource( @@ -456,12 +505,14 @@ def find_buckets_by_name( for bucket in s3_resource.buckets.all(): if name_contains in bucket.name: - self.logger.debug(f"Found matching bucket: {bucket.name}") + self.logger.debug(f"Found matching bucket: {safe_aws_ref(bucket.name)}") if include_features: - buckets[bucket.name] = self.get_bucket_features( - bucket_name=bucket.name, - execution_role_arn=role_arn, + buckets[bucket.name] = to_builtin( + self.get_bucket_features( + bucket_name=bucket.name, + execution_role_arn=role_arn, + ) ) else: buckets[bucket.name] = { @@ -470,7 +521,7 @@ def find_buckets_by_name( } self.logger.info(f"Found {len(buckets)} matching buckets") - return buckets + return self.extend_result(buckets) def create_bucket( self, @@ -478,9 +529,9 @@ def create_bucket( region: str | None = None, acl: str = "private", enable_versioning: bool = False, - tags: dict[str, str] | None = None, + tags: Mapping[str, str] | None = None, execution_role_arn: str | None = None, - ) -> dict[str, Any]: + ) -> ExtendedDict: """Create an S3 bucket. Args: @@ -494,7 +545,8 @@ def create_bucket( Returns: Create bucket response. """ - self.logger.info(f"Creating S3 bucket: {bucket_name}") + safe_bucket = safe_aws_ref(bucket_name) + self.logger.info(f"Creating S3 bucket: {safe_bucket}") role_arn = execution_role_arn or getattr(self, "execution_role_arn", None) s3 = self.get_aws_client( @@ -514,7 +566,7 @@ def create_bucket( } result = s3.create_bucket(**create_args) - self.logger.info(f"Created bucket: {bucket_name}") + self.logger.info(f"Created bucket: {safe_bucket}") # Enable versioning if requested if enable_versioning: @@ -522,18 +574,18 @@ def create_bucket( Bucket=bucket_name, VersioningConfiguration={"Status": "Enabled"}, ) - self.logger.info(f"Enabled versioning for bucket: {bucket_name}") + self.logger.info(f"Enabled versioning for bucket: {safe_bucket}") # Apply tags if provided if tags: - tag_set = [{"Key": k, "Value": v} for k, v in tags.items()] + tag_set = [{"Key": str(k), "Value": str(v)} for k, v in tags.items()] s3.put_bucket_tagging( Bucket=bucket_name, Tagging={"TagSet": tag_set}, ) - self.logger.info(f"Applied {len(tags)} tags to bucket: {bucket_name}") + self.logger.info(f"Applied {len(tags)} tags to bucket: {safe_bucket}") - return result + return self.extend_result(result) def delete_bucket( self, @@ -551,7 +603,8 @@ def delete_bucket( Raises: ClientError: If bucket not empty and force=False. """ - self.logger.info(f"Deleting S3 bucket: {bucket_name}") + safe_bucket = safe_aws_ref(bucket_name) + self.logger.info(f"Deleting S3 bucket: {safe_bucket}") role_arn = execution_role_arn or getattr(self, "execution_role_arn", None) if force: @@ -562,11 +615,11 @@ def delete_bucket( bucket = s3_resource.Bucket(bucket_name) # Delete all objects - self.logger.info(f"Deleting all objects in bucket: {bucket_name}") + self.logger.info(f"Deleting all objects in bucket: {safe_bucket}") bucket.objects.all().delete() # Delete all versions - self.logger.info(f"Deleting all versions in bucket: {bucket_name}") + self.logger.info(f"Deleting all versions in bucket: {safe_bucket}") bucket.object_versions.all().delete() s3 = self.get_aws_client( @@ -575,13 +628,13 @@ def delete_bucket( ) s3.delete_bucket(Bucket=bucket_name) - self.logger.info(f"Deleted bucket: {bucket_name}") + self.logger.info(f"Deleted bucket: {safe_bucket}") def get_bucket_tags( self, bucket_name: str, execution_role_arn: str | None = None, - ) -> dict[str, str]: + ) -> ExtendedDict: """Get tags for an S3 bucket. Args: @@ -600,16 +653,16 @@ def get_bucket_tags( try: response = s3.get_bucket_tagging(Bucket=bucket_name) - return {tag["Key"]: tag["Value"] for tag in response.get("TagSet", [])} + return self.extend_result({tag["Key"]: tag["Value"] for tag in response.get("TagSet", [])}) except ClientError as e: if e.response.get("Error", {}).get("Code") == "NoSuchTagSet": - return {} + return self.extend_result({}) raise def set_bucket_tags( self, bucket_name: str, - tags: dict[str, str], + tags: Mapping[str, str], execution_role_arn: str | None = None, ) -> None: """Set tags for an S3 bucket. @@ -619,7 +672,8 @@ def set_bucket_tags( tags: Dictionary of tag key-value pairs. execution_role_arn: ARN of role to assume for cross-account access. """ - self.logger.info(f"Setting {len(tags)} tags on bucket: {bucket_name}") + safe_bucket = safe_aws_ref(bucket_name) + self.logger.info(f"Setting {len(tags)} tags on bucket: {safe_bucket}") role_arn = execution_role_arn or getattr(self, "execution_role_arn", None) s3 = self.get_aws_client( @@ -627,18 +681,18 @@ def set_bucket_tags( execution_role_arn=role_arn, ) - tag_set = [{"Key": k, "Value": v} for k, v in tags.items()] + tag_set = [{"Key": str(k), "Value": str(v)} for k, v in tags.items()] s3.put_bucket_tagging( Bucket=bucket_name, Tagging={"TagSet": tag_set}, ) - self.logger.info(f"Set tags on bucket: {bucket_name}") + self.logger.info(f"Set tags on bucket: {safe_bucket}") def get_bucket_sizes( self, - bucket_names: list[str] | None = None, + bucket_names: Sequence[str] | None = None, execution_role_arn: str | None = None, - ) -> dict[str, dict[str, Any]]: + ) -> ExtendedDict: """Get sizes of S3 buckets using CloudWatch metrics. Args: @@ -671,6 +725,7 @@ def get_bucket_sizes( bucket_sizes: dict[str, dict[str, Any]] = {} for bucket_name in bucket_names: + safe_bucket = safe_aws_ref(bucket_name) size_bytes = 0 object_count = 0 @@ -691,7 +746,7 @@ def get_bucket_sizes( if size_response.get("Datapoints"): size_bytes = int(max(size_response["Datapoints"], key=lambda x: x["Timestamp"])["Average"]) except Exception as e: - self.logger.debug(f"Could not get size for {bucket_name}: {e}") + self.logger.debug(f"Could not get size for {safe_bucket}: {safe_aws_text(e, bucket_name)}") # Get object count try: @@ -710,7 +765,7 @@ def get_bucket_sizes( if count_response.get("Datapoints"): object_count = int(max(count_response["Datapoints"], key=lambda x: x["Timestamp"])["Average"]) except Exception as e: - self.logger.debug(f"Could not get count for {bucket_name}: {e}") + self.logger.debug(f"Could not get count for {safe_bucket}: {safe_aws_text(e, bucket_name)}") bucket_sizes[bucket_name] = { "size_bytes": size_bytes, @@ -719,4 +774,4 @@ def get_bucket_sizes( } self.logger.info(f"Retrieved sizes for {len(bucket_sizes)} buckets") - return bucket_sizes + return self.extend_result(bucket_sizes) diff --git a/src/extended_data/connectors/aws/sso.py b/src/extended_data/connectors/aws/sso.py index 04277b1..0634a44 100644 --- a/src/extended_data/connectors/aws/sso.py +++ b/src/extended_data/connectors/aws/sso.py @@ -6,12 +6,15 @@ from __future__ import annotations +from collections.abc import Mapping, Sequence from copy import deepcopy from typing import TYPE_CHECKING, Any from deepmerge import always_merger -from extended_data import is_nothing, unhump_map +from extended_data.connectors.aws._diagnostics import safe_aws_ref +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString, to_builtin +from extended_data.primitives import is_nothing, unhump_map if TYPE_CHECKING: @@ -27,10 +30,25 @@ class AWSSSOmixin: - execution_role_arn """ + if TYPE_CHECKING: + logger: Any + execution_role_arn: str | None + + def get_aws_client( + self, + client_name: str, + execution_role_arn: str | None = None, + role_session_name: str | None = None, + config: Any | None = None, + **client_args: Any, + ) -> Any: ... + + def extend_result(self, value: Any) -> Any: ... + def get_identity_store_id( self, execution_role_arn: str | None = None, - ) -> str: + ) -> ExtendedString: """Get the IAM Identity Center identity store ID. Args: @@ -58,13 +76,13 @@ def get_identity_store_id( raise RuntimeError(msg) identity_store_id = instance_list[0]["IdentityStoreId"] - self.logger.info(f"Identity store ID: {identity_store_id}") - return identity_store_id + self.logger.info(f"Identity store ID: {safe_aws_ref(identity_store_id)}") + return self.extend_result(identity_store_id) def get_sso_instance_arn( self, execution_role_arn: str | None = None, - ) -> str: + ) -> ExtendedString: """Get the IAM Identity Center instance ARN. Args: @@ -92,8 +110,8 @@ def get_sso_instance_arn( raise RuntimeError(msg) instance_arn = instance_list[0]["InstanceArn"] - self.logger.info(f"SSO instance ARN: {instance_arn}") - return instance_arn + self.logger.info(f"SSO instance ARN: {safe_aws_ref(instance_arn)}") + return self.extend_result(instance_arn) # ========================================================================= # Users @@ -106,7 +124,7 @@ def list_sso_users( flatten_name: bool = True, sort_by_name: bool = False, execution_role_arn: str | None = None, - ) -> dict[str, dict[str, Any]]: + ) -> ExtendedDict: """List all users from IAM Identity Center. Args: @@ -123,7 +141,7 @@ def list_sso_users( role_arn = execution_role_arn or getattr(self, "execution_role_arn", None) if not identity_store_id: - identity_store_id = self.get_identity_store_id(execution_role_arn=role_arn) + identity_store_id = str(self.get_identity_store_id(execution_role_arn=role_arn)) identitystore = self.get_aws_client( client_name="identitystore", @@ -164,14 +182,14 @@ def list_sso_users( users = {k: unhump_map(v) for k, v in users.items()} self.logger.info(f"Retrieved {len(users)} SSO users") - return users + return self.extend_result(users) def get_sso_user( self, user_id: str, identity_store_id: str | None = None, execution_role_arn: str | None = None, - ) -> dict[str, Any] | None: + ) -> ExtendedDict | None: """Get a specific SSO user by ID. Args: @@ -187,7 +205,7 @@ def get_sso_user( role_arn = execution_role_arn or getattr(self, "execution_role_arn", None) if not identity_store_id: - identity_store_id = self.get_identity_store_id(execution_role_arn=role_arn) + identity_store_id = str(self.get_identity_store_id(execution_role_arn=role_arn)) identitystore = self.get_aws_client( client_name="identitystore", @@ -195,9 +213,11 @@ def get_sso_user( ) try: - return identitystore.describe_user( - IdentityStoreId=identity_store_id, - UserId=user_id, + return self.extend_result( + identitystore.describe_user( + IdentityStoreId=identity_store_id, + UserId=user_id, + ) ) except ClientError as e: if e.response.get("Error", {}).get("Code") == "ResourceNotFoundException": @@ -210,10 +230,10 @@ def create_sso_user( display_name: str, given_name: str | None = None, family_name: str | None = None, - emails: list[dict[str, Any]] | None = None, + emails: Sequence[Mapping[str, Any]] | None = None, identity_store_id: str | None = None, execution_role_arn: str | None = None, - ) -> dict[str, Any]: + ) -> ExtendedDict: """Create a user in IAM Identity Center. Args: @@ -228,11 +248,12 @@ def create_sso_user( Returns: Created user response. """ - self.logger.info(f"Creating SSO user: {user_name}") + safe_user_name = safe_aws_ref(user_name) + self.logger.info(f"Creating SSO user: {safe_user_name}") role_arn = execution_role_arn or getattr(self, "execution_role_arn", None) if not identity_store_id: - identity_store_id = self.get_identity_store_id(execution_role_arn=role_arn) + identity_store_id = str(self.get_identity_store_id(execution_role_arn=role_arn)) identitystore = self.get_aws_client( client_name="identitystore", @@ -253,11 +274,11 @@ def create_sso_user( user_body["Name"]["FamilyName"] = family_name if emails: - user_body["Emails"] = emails + user_body["Emails"] = to_builtin(list(emails)) result = identitystore.create_user(**user_body) - self.logger.info(f"Created SSO user: {user_name} ({result.get('UserId')})") - return result + self.logger.info(f"Created SSO user: {safe_user_name} ({safe_aws_ref(result.get('UserId'))})") + return self.extend_result(result) def delete_sso_user( self, @@ -272,11 +293,12 @@ def delete_sso_user( identity_store_id: Identity store ID. Auto-detected if not provided. execution_role_arn: ARN of role to assume for cross-account access. """ - self.logger.info(f"Deleting SSO user: {user_id}") + safe_user_id = safe_aws_ref(user_id) + self.logger.info(f"Deleting SSO user: {safe_user_id}") role_arn = execution_role_arn or getattr(self, "execution_role_arn", None) if not identity_store_id: - identity_store_id = self.get_identity_store_id(execution_role_arn=role_arn) + identity_store_id = str(self.get_identity_store_id(execution_role_arn=role_arn)) identitystore = self.get_aws_client( client_name="identitystore", @@ -287,7 +309,7 @@ def delete_sso_user( IdentityStoreId=identity_store_id, UserId=user_id, ) - self.logger.info(f"Deleted SSO user: {user_id}") + self.logger.info(f"Deleted SSO user: {safe_user_id}") # ========================================================================= # Groups @@ -298,10 +320,10 @@ def list_sso_groups( identity_store_id: str | None = None, unhump_groups: bool = True, expand_members: bool = False, - users: dict[str, dict[str, Any]] | None = None, + users: Mapping[str, Mapping[str, Any]] | None = None, sort_by_name: bool = False, execution_role_arn: str | None = None, - ) -> dict[str, dict[str, Any]]: + ) -> ExtendedDict: """List all groups from IAM Identity Center. Args: @@ -319,7 +341,7 @@ def list_sso_groups( role_arn = execution_role_arn or getattr(self, "execution_role_arn", None) if not identity_store_id: - identity_store_id = self.get_identity_store_id(execution_role_arn=role_arn) + identity_store_id = str(self.get_identity_store_id(execution_role_arn=role_arn)) # Pre-fetch users if expanding members if expand_members and not users: @@ -372,7 +394,7 @@ def list_sso_groups( groups = {k: unhump_map(v) for k, v in groups.items()} self.logger.info(f"Retrieved {len(groups)} SSO groups") - return groups + return self.extend_result(groups) def _get_group_members( self, @@ -380,8 +402,8 @@ def _get_group_members( identity_store_id: str, identitystore: Any, expand_members: bool = False, - users: dict[str, dict[str, Any]] | None = None, - ) -> list[str] | dict[str, dict[str, Any]]: + users: Mapping[str, Mapping[str, Any]] | None = None, + ) -> list[str] | dict[str, Mapping[str, Any]]: """Get members of an SSO group. Args: @@ -394,7 +416,7 @@ def _get_group_members( Returns: List of user IDs or dict mapping user IDs to user data. """ - members: list[str] | dict[str, dict[str, Any]] = {} if expand_members else [] + members: list[str] | dict[str, Mapping[str, Any]] = {} if expand_members else [] page_token: str | None = None while True: @@ -430,7 +452,7 @@ def create_sso_group( description: str = "", identity_store_id: str | None = None, execution_role_arn: str | None = None, - ) -> dict[str, Any]: + ) -> ExtendedDict: """Create a group in IAM Identity Center. Args: @@ -442,11 +464,12 @@ def create_sso_group( Returns: Created group response. """ - self.logger.info(f"Creating SSO group: {display_name}") + safe_display_name = safe_aws_ref(display_name) + self.logger.info(f"Creating SSO group: {safe_display_name}") role_arn = execution_role_arn or getattr(self, "execution_role_arn", None) if not identity_store_id: - identity_store_id = self.get_identity_store_id(execution_role_arn=role_arn) + identity_store_id = str(self.get_identity_store_id(execution_role_arn=role_arn)) identitystore = self.get_aws_client( client_name="identitystore", @@ -458,8 +481,8 @@ def create_sso_group( DisplayName=display_name, Description=description, ) - self.logger.info(f"Created SSO group: {display_name} ({result.get('GroupId')})") - return result + self.logger.info(f"Created SSO group: {safe_display_name} ({safe_aws_ref(result.get('GroupId'))})") + return self.extend_result(result) def delete_sso_group( self, @@ -474,11 +497,12 @@ def delete_sso_group( identity_store_id: Identity store ID. Auto-detected if not provided. execution_role_arn: ARN of role to assume for cross-account access. """ - self.logger.info(f"Deleting SSO group: {group_id}") + safe_group_id = safe_aws_ref(group_id) + self.logger.info(f"Deleting SSO group: {safe_group_id}") role_arn = execution_role_arn or getattr(self, "execution_role_arn", None) if not identity_store_id: - identity_store_id = self.get_identity_store_id(execution_role_arn=role_arn) + identity_store_id = str(self.get_identity_store_id(execution_role_arn=role_arn)) identitystore = self.get_aws_client( client_name="identitystore", @@ -489,7 +513,7 @@ def delete_sso_group( IdentityStoreId=identity_store_id, GroupId=group_id, ) - self.logger.info(f"Deleted SSO group: {group_id}") + self.logger.info(f"Deleted SSO group: {safe_group_id}") def add_user_to_group( self, @@ -497,7 +521,7 @@ def add_user_to_group( group_id: str, identity_store_id: str | None = None, execution_role_arn: str | None = None, - ) -> dict[str, Any]: + ) -> ExtendedDict: """Add a user to an SSO group. Args: @@ -509,11 +533,13 @@ def add_user_to_group( Returns: Membership response. """ - self.logger.info(f"Adding user {user_id} to group {group_id}") + safe_user_id = safe_aws_ref(user_id) + safe_group_id = safe_aws_ref(group_id) + self.logger.info(f"Adding user {safe_user_id} to group {safe_group_id}") role_arn = execution_role_arn or getattr(self, "execution_role_arn", None) if not identity_store_id: - identity_store_id = self.get_identity_store_id(execution_role_arn=role_arn) + identity_store_id = str(self.get_identity_store_id(execution_role_arn=role_arn)) identitystore = self.get_aws_client( client_name="identitystore", @@ -525,8 +551,8 @@ def add_user_to_group( GroupId=group_id, MemberId={"UserId": user_id}, ) - self.logger.info(f"Added user {user_id} to group {group_id}") - return result + self.logger.info(f"Added user {safe_user_id} to group {safe_group_id}") + return self.extend_result(result) def remove_user_from_group( self, @@ -541,11 +567,12 @@ def remove_user_from_group( identity_store_id: Identity store ID. Auto-detected if not provided. execution_role_arn: ARN of role to assume for cross-account access. """ - self.logger.info(f"Removing membership: {membership_id}") + safe_membership_id = safe_aws_ref(membership_id) + self.logger.info(f"Removing membership: {safe_membership_id}") role_arn = execution_role_arn or getattr(self, "execution_role_arn", None) if not identity_store_id: - identity_store_id = self.get_identity_store_id(execution_role_arn=role_arn) + identity_store_id = str(self.get_identity_store_id(execution_role_arn=role_arn)) identitystore = self.get_aws_client( client_name="identitystore", @@ -556,7 +583,7 @@ def remove_user_from_group( IdentityStoreId=identity_store_id, MembershipId=membership_id, ) - self.logger.info(f"Removed membership: {membership_id}") + self.logger.info(f"Removed membership: {safe_membership_id}") # ========================================================================= # Permission Sets @@ -570,7 +597,7 @@ def list_permission_sets( unhump_sets: bool = True, sort_by_name: bool = False, execution_role_arn: str | None = None, - ) -> dict[str, dict[str, Any]]: + ) -> ExtendedDict: """List all permission sets from IAM Identity Center. Args: @@ -588,7 +615,7 @@ def list_permission_sets( role_arn = execution_role_arn or getattr(self, "execution_role_arn", None) if not instance_arn: - instance_arn = self.get_sso_instance_arn(execution_role_arn=role_arn) + instance_arn = str(self.get_sso_instance_arn(execution_role_arn=role_arn)) sso_admin = self.get_aws_client( client_name="sso-admin", @@ -647,7 +674,7 @@ def list_permission_sets( permission_sets = {k: unhump_map(v) for k, v in permission_sets.items()} self.logger.info(f"Retrieved {len(permission_sets)} permission sets") - return permission_sets + return self.extend_result(permission_sets) def _get_managed_policies_for_permission_set( self, @@ -687,7 +714,7 @@ def list_account_assignments( instance_arn: str | None = None, unhump_assignments: bool = True, execution_role_arn: str | None = None, - ) -> list[dict[str, Any]]: + ) -> ExtendedList[ExtendedDict]: """List account assignments for a permission set. Args: @@ -700,11 +727,12 @@ def list_account_assignments( Returns: List of account assignment dictionaries. """ - self.logger.info(f"Listing account assignments for {account_id}") + safe_account_id = safe_aws_ref(account_id) + self.logger.info(f"Listing account assignments for {safe_account_id}") role_arn = execution_role_arn or getattr(self, "execution_role_arn", None) if not instance_arn: - instance_arn = self.get_sso_instance_arn(execution_role_arn=role_arn) + instance_arn = str(self.get_sso_instance_arn(execution_role_arn=role_arn)) sso_admin = self.get_aws_client( client_name="sso-admin", @@ -733,8 +761,8 @@ def list_account_assignments( if unhump_assignments: assignments = [unhump_map(a) for a in assignments] - self.logger.info(f"Retrieved {len(assignments)} assignments for {account_id}") - return assignments + self.logger.info(f"Retrieved {len(assignments)} assignments for {safe_account_id}") + return self.extend_result(assignments) def create_account_assignment( self, @@ -744,7 +772,7 @@ def create_account_assignment( principal_type: str, instance_arn: str | None = None, execution_role_arn: str | None = None, - ) -> dict[str, Any]: + ) -> ExtendedDict: """Create an account assignment. Args: @@ -758,11 +786,13 @@ def create_account_assignment( Returns: Account assignment creation status. """ - self.logger.info(f"Creating account assignment: {principal_type} {principal_id} -> {account_id}") + safe_principal_id = safe_aws_ref(principal_id) + safe_account_id = safe_aws_ref(account_id) + self.logger.info(f"Creating account assignment: {principal_type} {safe_principal_id} -> {safe_account_id}") role_arn = execution_role_arn or getattr(self, "execution_role_arn", None) if not instance_arn: - instance_arn = self.get_sso_instance_arn(execution_role_arn=role_arn) + instance_arn = str(self.get_sso_instance_arn(execution_role_arn=role_arn)) sso_admin = self.get_aws_client( client_name="sso-admin", @@ -777,8 +807,8 @@ def create_account_assignment( PrincipalType=principal_type, PrincipalId=principal_id, ) - self.logger.info(f"Created account assignment for {principal_id}") - return result + self.logger.info(f"Created account assignment for {safe_principal_id}") + return self.extend_result(result) def delete_account_assignment( self, @@ -788,7 +818,7 @@ def delete_account_assignment( principal_type: str, instance_arn: str | None = None, execution_role_arn: str | None = None, - ) -> dict[str, Any]: + ) -> ExtendedDict: """Delete an account assignment. Args: @@ -802,11 +832,13 @@ def delete_account_assignment( Returns: Account assignment deletion status. """ - self.logger.info(f"Deleting account assignment: {principal_type} {principal_id} -> {account_id}") + safe_principal_id = safe_aws_ref(principal_id) + safe_account_id = safe_aws_ref(account_id) + self.logger.info(f"Deleting account assignment: {principal_type} {safe_principal_id} -> {safe_account_id}") role_arn = execution_role_arn or getattr(self, "execution_role_arn", None) if not instance_arn: - instance_arn = self.get_sso_instance_arn(execution_role_arn=role_arn) + instance_arn = str(self.get_sso_instance_arn(execution_role_arn=role_arn)) sso_admin = self.get_aws_client( client_name="sso-admin", @@ -821,5 +853,5 @@ def delete_account_assignment( PrincipalType=principal_type, PrincipalId=principal_id, ) - self.logger.info(f"Deleted account assignment for {principal_id}") - return result + self.logger.info(f"Deleted account assignment for {safe_principal_id}") + return self.extend_result(result) diff --git a/src/extended_data/connectors/aws/tools.py b/src/extended_data/connectors/aws/tools.py index 7eca792..157a339 100644 --- a/src/extended_data/connectors/aws/tools.py +++ b/src/extended_data/connectors/aws/tools.py @@ -27,10 +27,14 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any from pydantic import BaseModel, Field +from extended_data.connectors.ai_tools import raise_unknown_tool_framework +from extended_data.containers import ExtendedDict, ExtendedList, extend_data + # ============================================================================= # Input Schemas @@ -81,40 +85,42 @@ class GetSecretSchema(BaseModel): # ============================================================================= -def get_caller_account_id() -> dict[str, str]: +def get_caller_account_id() -> ExtendedDict: """Get the AWS account ID of the caller. Returns: Dict with account_id field. """ - from extended_data.connectors.aws import AWSConnectorFull + from extended_data.connectors.aws import AWSConnector - connector = AWSConnectorFull() + connector = AWSConnector() account_id = connector.get_caller_account_id() - return {"account_id": account_id} + return extend_data({"account_id": account_id}) -def list_s3_buckets() -> list[dict[str, Any]]: +def list_s3_buckets() -> ExtendedList[ExtendedDict]: """List S3 buckets in the account. Returns: List of bucket info (name, creation_date, region). """ - from extended_data.connectors.aws import AWSConnectorFull + from extended_data.connectors.aws import AWSConnector - connector = AWSConnectorFull() + connector = AWSConnector() buckets = connector.list_s3_buckets() - return [ - { - "name": name, - "creation_date": str(data.get("CreationDate", "")), - "region": data.get("region", ""), - } - for name, data in buckets.items() - ] - - -def list_s3_objects(bucket: str) -> list[dict[str, Any]]: + return extend_data( + [ + { + "name": name, + "creation_date": str(data.get("CreationDate", "")), + "region": data.get("region", ""), + } + for name, data in buckets.items() + ] + ) + + +def list_s3_objects(bucket: str) -> ExtendedList[ExtendedDict]: """List objects in an S3 bucket. Args: @@ -123,86 +129,101 @@ def list_s3_objects(bucket: str) -> list[dict[str, Any]]: Returns: List of object info (key, size, last_modified). """ - from extended_data.connectors.aws import AWSConnectorFull - - connector = AWSConnectorFull() - objects = connector.list_objects(bucket) - return [ - { - "key": key, - "size": data.get("Size", 0), - "last_modified": str(data.get("LastModified", "")), - } - for key, data in objects.items() - ] + from extended_data.connectors.aws import AWSConnector + + connector = AWSConnector() + objects_raw: Any = connector.list_objects(bucket) + if isinstance(objects_raw, Mapping): + objects = [{"key": key, **data} for key, data in objects_raw.items()] + else: + objects = objects_raw + + result: list[dict[str, Any]] = [] + for data in objects: + if not isinstance(data, Mapping): + continue + result.append( + { + "key": data.get("key", data.get("Key", "")), + "size": data.get("size", data.get("Size", 0)), + "last_modified": str(data.get("last_modified", data.get("LastModified", ""))), + } + ) + return extend_data(result) -def list_accounts() -> list[dict[str, Any]]: +def list_accounts() -> ExtendedList[ExtendedDict]: """List AWS organization accounts. Returns: List of account info (id, name, email, status). """ - from extended_data.connectors.aws import AWSConnectorFull + from extended_data.connectors.aws import AWSConnector - connector = AWSConnectorFull() + connector = AWSConnector() accounts = connector.get_accounts() - return [ - { - "id": acc_id, - "name": data.get("Name", ""), - "email": data.get("Email", ""), - "status": data.get("Status", ""), - } - for acc_id, data in accounts.items() - ] - - -def list_sso_users() -> list[dict[str, Any]]: + return extend_data( + [ + { + "id": acc_id, + "name": data.get("Name", ""), + "email": data.get("Email", ""), + "status": data.get("Status", ""), + } + for acc_id, data in accounts.items() + ] + ) + + +def list_sso_users() -> ExtendedList[ExtendedDict]: """List IAM Identity Center users. Returns: List of user info (user_id, user_name, display_name, email). """ - from extended_data.connectors.aws import AWSConnectorFull + from extended_data.connectors.aws import AWSConnector - connector = AWSConnectorFull() + connector = AWSConnector() users = connector.list_sso_users() - return [ - { - "user_id": user_id, - "user_name": data.get("user_name", ""), - "display_name": data.get("display_name", ""), - "email": data.get("primary_email", {}).get("value", ""), - } - for user_id, data in users.items() - ] - - -def list_sso_groups() -> list[dict[str, Any]]: + return extend_data( + [ + { + "user_id": user_id, + "user_name": data.get("user_name", ""), + "display_name": data.get("display_name", ""), + "email": data.get("primary_email", {}).get("value", ""), + } + for user_id, data in users.items() + ] + ) + + +def list_sso_groups() -> ExtendedList[ExtendedDict]: """List IAM Identity Center groups. Returns: List of group info (group_id, display_name, member_count). """ - from extended_data.connectors.aws import AWSConnectorFull + from extended_data.connectors.aws import AWSConnector - connector = AWSConnectorFull() + connector = AWSConnector() groups = connector.list_sso_groups() - return [ - { - "group_id": group_id, - "display_name": data.get("display_name", ""), - "member_count": len(data.get("members", [])), - } - for group_id, data in groups.items() - ] + return extend_data( + [ + { + "group_id": group_id, + "display_name": data.get("display_name", ""), + "member_count": len(data.get("members", [])), + } + for group_id, data in groups.items() + ] + ) def list_secrets( prefix: str = "", get_values: bool = False, -) -> list[dict[str, Any]]: +) -> ExtendedList[ExtendedDict]: """List secrets from AWS Secrets Manager. Args: @@ -212,11 +233,11 @@ def list_secrets( Returns: List of secret info (name, arn, value). """ - from extended_data.connectors.aws import AWSConnectorFull + from extended_data.connectors.aws import AWSConnector - connector = AWSConnectorFull() + connector = AWSConnector() # Align with tests: only pass arguments that match test expectations - kwargs = {} + kwargs: dict[str, Any] = {} if prefix: kwargs["prefix"] = prefix if get_values: @@ -224,16 +245,18 @@ def list_secrets( secrets = connector.list_secrets(**kwargs) - result = [] + result: list[dict[str, Any]] = [] for name, data in secrets.items(): if isinstance(data, str): result.append({"name": name, "arn": data}) + elif data is None: + result.append({"name": name, "arn": None, "value": None}) else: result.append({"name": name, "arn": data.get("ARN"), "value": data}) - return result + return extend_data(result) -def get_secret(secret_id: str) -> dict[str, Any]: +def get_secret(secret_id: str) -> ExtendedDict: """Get a single secret value from AWS Secrets Manager. Args: @@ -242,15 +265,17 @@ def get_secret(secret_id: str) -> dict[str, Any]: Returns: Dict with secret_name, secret_value, and status. """ - from extended_data.connectors.aws import AWSConnectorFull + from extended_data.connectors.aws import AWSConnector - connector = AWSConnectorFull() + connector = AWSConnector() value = connector.get_secret(secret_id) - return { - "secret_name": secret_id, - "secret_value": value, - "status": "retrieved" if value is not None else "not_found", - } + return extend_data( + { + "secret_name": secret_id, + "secret_value": value, + "status": "retrieved" if value is not None else "not_found", + } + ) # ============================================================================= @@ -316,30 +341,16 @@ def get_secret(secret_id: str) -> dict[str, Any]: def get_langchain_tools() -> list[Any]: """Get all AWS tools as LangChain StructuredTools.""" - try: - from langchain_core.tools import StructuredTool - except ImportError as e: - msg = "langchain-core is required for LangChain tools.\nInstall with: pip install extended-data[langchain]" - raise ImportError(msg) from e - - return [ - StructuredTool.from_function( - func=defn["func"], - name=defn["name"], - description=defn["description"], - args_schema=defn.get("schema") or defn.get("args_schema"), - ) - for defn in TOOL_DEFINITIONS - ] + from extended_data.connectors.ai_tools import build_langchain_tools + + return build_langchain_tools(TOOL_DEFINITIONS) def get_crewai_tools() -> list[Any]: """Get all AWS tools as CrewAI tools.""" - try: - from crewai.tools import tool as crewai_tool - except ImportError as e: - msg = "crewai is required for CrewAI tools.\nInstall with: pip install extended-data[crewai]" - raise ImportError(msg) from e + from extended_data.connectors._optional import get_crewai_tool_decorator + + crewai_tool = get_crewai_tool_decorator() tools = [] for defn in TOOL_DEFINITIONS: @@ -373,10 +384,10 @@ def get_tools(framework: str = "auto") -> list[Any]: return get_langchain_tools() if framework == "crewai": return get_crewai_tools() - if framework in ("strands", "functions"): + if framework == "strands": return get_strands_tools() - raise ValueError(f"Unknown framework: {framework}") + return raise_unknown_tool_framework(framework) # ============================================================================= diff --git a/src/extended_data/connectors/base.py b/src/extended_data/connectors/base.py index c1349a0..12f6a37 100644 --- a/src/extended_data/connectors/base.py +++ b/src/extended_data/connectors/base.py @@ -1,7 +1,7 @@ """Base class for all extended data connectors. -This module provides VendorConnectorBase - the foundation for ALL connectors -in this library. It extends InputProvider and provides: +This module provides ConnectorBase - the foundation for ALL connectors +in the package connector fabric. It extends InputProvider and provides: 1. Credential loading from env vars, stdin, or direct inputs 2. HTTP client with retries and rate limiting @@ -11,9 +11,9 @@ ALL connectors should extend this class instead of InputProvider directly. Usage: - from extended_data.connectors.base import VendorConnectorBase + from extended_data import ExtendedDict, ConnectorBase - class MyConnector(VendorConnectorBase): + class MyConnector(ConnectorBase): API_KEY_ENV = "MY_API_KEY" # Required env var name BASE_URL = "https://api.example.com" @@ -21,23 +21,26 @@ def __init__(self, api_key: str | None = None, **kwargs): super().__init__(**kwargs) self._api_key = api_key or self.get_input(self.API_KEY_ENV, required=True) - def my_operation(self) -> dict: - return self.request("GET", "/endpoint") + def my_operation(self) -> ExtendedDict: + return self.request_data("GET", "/endpoint", suffix="json") """ from __future__ import annotations import builtins +import sys import threading import time from abc import ABC +from collections.abc import Mapping +from contextlib import suppress from typing import TYPE_CHECKING, Any, ClassVar import httpx from tenacity import ( - retry, + Retrying, retry_if_exception_type, stop_after_attempt, wait_exponential, @@ -45,14 +48,25 @@ def my_operation(self) -> dict: from extended_data.inputs import InputProvider from extended_data.logging import Logging +from extended_data.primitives.redaction import redact_sensitive_text +if sys.version_info >= (3, 11): + from typing import Self +else: + from typing_extensions import Self + if TYPE_CHECKING: from collections.abc import Callable + from types import TracebackType from langchain_core.tools import StructuredTool from pydantic import BaseModel + from extended_data.containers import ExtendedDict, ExtendedList + from extended_data.io import DataFile + from extended_data.workflows import DataWorkflow + class RateLimitError(Exception): """Raised when API rate limit is hit - triggers retry.""" @@ -66,7 +80,7 @@ def __init__(self, message: str, status_code: int | None = None): self.status_code = status_code -class VendorConnectorBase(InputProvider, ABC): +class ConnectorBase(InputProvider, ABC): """Base class for all extended data connectors. Provides: @@ -80,6 +94,8 @@ class VendorConnectorBase(InputProvider, ABC): Class Attributes: BASE_URL: API base URL (required for HTTP connectors) API_KEY_ENV: Environment variable name for API key + CONNECTOR_CATEGORY: Catalog category for registry metadata + CONNECTOR_CAPABILITIES: Catalog capabilities for registry metadata TIMEOUT: HTTP timeout in seconds (default 300) MIN_REQUEST_INTERVAL: Minimum seconds between requests (rate limiting) MAX_RETRIES: Maximum retry attempts (default 5) @@ -93,6 +109,8 @@ class VendorConnectorBase(InputProvider, ABC): # Class-level configuration - override in subclasses BASE_URL: ClassVar[str] = "" API_KEY_ENV: ClassVar[str] = "" + CONNECTOR_CATEGORY: ClassVar[str] = "external" + CONNECTOR_CAPABILITIES: ClassVar[tuple[str, ...]] = () TIMEOUT: ClassVar[float] = 300.0 MIN_REQUEST_INTERVAL: ClassVar[float] = 0.0 # No rate limit by default MAX_RETRIES: ClassVar[int] = 5 @@ -101,8 +119,8 @@ class VendorConnectorBase(InputProvider, ABC): # Each subclass gets its own lock and timestamp to avoid cross-connector interference. # This is intentionally class-level (not instance-level) so all instances of the same # connector type share rate limiting, but different connector types are independent. - _rate_limit_locks: ClassVar[dict[builtins.type[VendorConnectorBase], threading.Lock]] = {} - _last_request_times: ClassVar[dict[builtins.type[VendorConnectorBase], float]] = {} + _rate_limit_locks: ClassVar[dict[builtins.type[ConnectorBase], threading.Lock]] = {} + _last_request_times: ClassVar[dict[builtins.type[ConnectorBase], float]] = {} def __init__( self, @@ -110,8 +128,8 @@ def __init__( base_url: str | None = None, timeout: float | None = None, logger: Logging | None = None, - **kwargs, - ): + **kwargs: Any, + ) -> None: """Initialize the connector. Args: @@ -143,7 +161,7 @@ def __init__( # Tool registry for LangChain/MCP self._tools: list[StructuredTool] = [] - self._tool_functions: dict[str, Callable] = {} + self._tool_functions: dict[str, Callable[..., Any]] = {} self._tool_schemas: dict[str, builtins.type[BaseModel]] = {} @property @@ -167,11 +185,16 @@ def close(self) -> None: self._client.close() self._client = None - def __enter__(self): + def __enter__(self) -> Self: """Context manager entry.""" return self - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__( + self, + exc_type: builtins.type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: """Context manager exit - close client.""" self.close() @@ -217,34 +240,22 @@ def _build_url(self, endpoint: str) -> str: endpoint = endpoint.lstrip("/") return f"{base}/{endpoint}" - @retry( - retry=retry_if_exception_type((RateLimitError, httpx.TimeoutException)), - stop=stop_after_attempt(5), - wait=wait_exponential(multiplier=1, min=2, max=30), - ) - def request( + def _max_retry_attempts(self) -> int: + """Return the validated retry attempt count for this connector.""" + if self.MAX_RETRIES < 1: + msg = f"{type(self).__name__}.MAX_RETRIES must be at least 1" + raise ValueError(msg) + return self.MAX_RETRIES + + def _request_once( self, method: str, endpoint: str, *, headers: dict[str, str] | None = None, - **kwargs, + **kwargs: Any, ) -> httpx.Response: - """Make HTTP request with retries and rate limiting. - - Args: - method: HTTP method (GET, POST, PUT, DELETE, etc.) - endpoint: API endpoint (relative to BASE_URL) - headers: Additional headers (merged with defaults) - **kwargs: Passed to httpx.request (json, params, data, etc.) - - Returns: - httpx.Response - - Raises: - RateLimitError: On 429 (will retry automatically) - ConnectorAPIError: On other API errors - """ + """Make one HTTP request attempt with rate limiting and response handling.""" self._rate_limit() url = self._build_url(endpoint) @@ -266,36 +277,336 @@ def request( # Retry on 5xx server errors if response.status_code >= 500: - msg = f"Server error {response.status_code}: {response.text}" + msg = f"Server error {response.status_code}: {redact_sensitive_text(response.text)}" raise RateLimitError(msg) # Raise on 4xx client errors (don't retry) if response.status_code >= 400: - msg = f"API error {response.status_code}: {response.text}" + msg = f"API error {response.status_code}: {redact_sensitive_text(response.text)}" raise ConnectorAPIError(msg, status_code=response.status_code) return response - def get(self, endpoint: str, **kwargs) -> httpx.Response: + def request( + self, + method: str, + endpoint: str, + *, + headers: dict[str, str] | None = None, + **kwargs: Any, + ) -> httpx.Response: + """Make HTTP request with retries and rate limiting. + + Args: + method: HTTP method (GET, POST, PUT, DELETE, etc.) + endpoint: API endpoint (relative to BASE_URL) + headers: Additional headers (merged with defaults) + **kwargs: Passed to httpx.request (json, params, data, etc.) + + Returns: + httpx.Response + + Raises: + RateLimitError: On 429 or 5xx responses after retries are exhausted. + ConnectorAPIError: On other API errors. + """ + retryer = Retrying( + retry=retry_if_exception_type((RateLimitError, httpx.TimeoutException)), + stop=stop_after_attempt(self._max_retry_attempts()), + wait=wait_exponential(multiplier=1, min=2, max=30), + sleep=time.sleep, + reraise=True, + ) + + for attempt in retryer: + with attempt: + return self._request_once(method, endpoint, headers=headers, **kwargs) + + message = "Retry loop exited without returning or raising." + raise RuntimeError(message) + + @staticmethod + def _suffix_from_content_type(content_type: str | None) -> str | None: + """Infer a data suffix from an HTTP Content-Type header.""" + if not content_type: + return None + + media_type = content_type.split(";", maxsplit=1)[0].strip().lower() + if media_type == "application/json" or media_type.endswith("+json"): + return "json" + if media_type in {"application/yaml", "application/x-yaml", "text/yaml", "text/x-yaml"} or media_type.endswith( + "+yaml" + ): + return "yaml" + if media_type in {"application/toml", "text/toml"}: + return "toml" + if media_type in {"application/hcl", "text/hcl"}: + return "hcl" + if media_type.startswith("text/"): + return "raw" + return None + + def decode_response( + self, + response: httpx.Response, + *, + suffix: str | None = None, + as_extended: bool = True, + ) -> Any: + """Decode an HTTP response body through the extended-data IO layer. + + Structured response bodies are decoded from JSON, YAML, TOML, or HCL and + promoted to Tier 2 containers by default. Text responses become raw + strings, and unknown binary responses remain bytes. + """ + if not response.content: + return None + + resolved_suffix = suffix or self._suffix_from_content_type(response.headers.get("content-type")) + if resolved_suffix is None: + return response.content + + from extended_data.io.files import decode_file + + return decode_file(response.content, suffix=resolved_suffix, as_extended=as_extended) + + @staticmethod + def _response_source(response: httpx.Response, fallback: str | None = None) -> str: + """Return a stable source label for a response artifact.""" + if fallback: + return fallback + try: + return str(response.request.url) + except RuntimeError: + return "response" + + @staticmethod + def _response_metadata(response: httpx.Response, metadata: Mapping[str, Any] | None = None) -> dict[str, Any]: + """Return non-secret response provenance for a DataFile artifact.""" + response_metadata: dict[str, Any] = { + "status_code": response.status_code, + "content_type": response.headers.get("content-type", ""), + } + with suppress(RuntimeError): + response_metadata["method"] = response.request.method + if metadata: + response_metadata.update(metadata) + return response_metadata + + def decode_response_file( + self, + response: httpx.Response, + *, + source: str | None = None, + suffix: str | None = None, + as_extended: bool = True, + metadata: Mapping[str, Any] | None = None, + ) -> DataFile: + """Decode an HTTP response body into a DataFile artifact with provenance.""" + from extended_data.containers import ExtendedDict, ExtendedString + from extended_data.io import DataFile + + resolved_suffix = suffix or self._suffix_from_content_type(response.headers.get("content-type")) + artifact_source = self._response_source(response, fallback=source) + artifact_metadata = self._response_metadata(response, metadata=metadata) + + if not response.content: + return DataFile( + source=ExtendedString(artifact_source), + data=None, + encoding=ExtendedString(resolved_suffix or "raw"), + metadata=ExtendedDict( + { + "source": artifact_source, + "encoding": resolved_suffix or "raw", + "path": None, + "is_url": artifact_source.startswith(("http://", "https://")), + "data_type": "NoneType", + **artifact_metadata, + } + ), + ) + + if resolved_suffix is None: + return DataFile( + source=ExtendedString(artifact_source), + data=response.content, + encoding=ExtendedString("raw"), + metadata=ExtendedDict( + { + "source": artifact_source, + "encoding": "raw", + "path": None, + "is_url": artifact_source.startswith(("http://", "https://")), + "data_type": type(response.content).__name__, + **artifact_metadata, + } + ), + ) + + return DataFile.decode( + response.content, + file_path=artifact_source, + suffix=resolved_suffix, + as_extended=as_extended, + metadata=artifact_metadata, + ) + + def extend_result(self, value: Any) -> Any: + """Promote connector data payloads into Tier 2 containers.""" + from extended_data.containers import extend_data + + return extend_data(value) + + def request_data( + self, + method: str, + endpoint: str, + *, + headers: dict[str, str] | None = None, + suffix: str | None = None, + as_extended: bool = True, + **kwargs: Any, + ) -> Any: + """Make an HTTP request and return decoded response data.""" + response = self.request(method, endpoint, headers=headers, **kwargs) + return self.decode_response(response, suffix=suffix, as_extended=as_extended) + + def request_data_file( + self, + method: str, + endpoint: str, + *, + headers: dict[str, str] | None = None, + suffix: str | None = None, + as_extended: bool = True, + **kwargs: Any, + ) -> DataFile: + """Make an HTTP request and return a decoded DataFile response artifact.""" + response = self.request(method, endpoint, headers=headers, **kwargs) + return self.decode_response_file( + response, + source=self._build_url(endpoint), + suffix=suffix, + as_extended=as_extended, + metadata={"method": method.upper(), "endpoint": endpoint}, + ) + + def request_workflow( + self, + method: str, + endpoint: str, + *, + headers: dict[str, str] | None = None, + suffix: str | None = None, + as_extended: bool = True, + **kwargs: Any, + ) -> DataWorkflow: + """Make an HTTP request and return a workflow over the decoded response artifact.""" + return self.request_data_file( + method, + endpoint, + headers=headers, + suffix=suffix, + as_extended=as_extended, + **kwargs, + ).workflow(as_extended=as_extended) + + def get(self, endpoint: str, **kwargs: Any) -> httpx.Response: """HTTP GET request.""" return self.request("GET", endpoint, **kwargs) - def post(self, endpoint: str, **kwargs) -> httpx.Response: + def get_data(self, endpoint: str, *, suffix: str | None = None, as_extended: bool = True, **kwargs: Any) -> Any: + """HTTP GET request returning decoded response data.""" + return self.request_data("GET", endpoint, suffix=suffix, as_extended=as_extended, **kwargs) + + def get_workflow( + self, + endpoint: str, + *, + suffix: str | None = None, + as_extended: bool = True, + **kwargs: Any, + ) -> DataWorkflow: + """HTTP GET request returning a workflow over decoded response data.""" + return self.request_workflow("GET", endpoint, suffix=suffix, as_extended=as_extended, **kwargs) + + def post(self, endpoint: str, **kwargs: Any) -> httpx.Response: """HTTP POST request.""" return self.request("POST", endpoint, **kwargs) - def put(self, endpoint: str, **kwargs) -> httpx.Response: + def post_data(self, endpoint: str, *, suffix: str | None = None, as_extended: bool = True, **kwargs: Any) -> Any: + """HTTP POST request returning decoded response data.""" + return self.request_data("POST", endpoint, suffix=suffix, as_extended=as_extended, **kwargs) + + def post_workflow( + self, + endpoint: str, + *, + suffix: str | None = None, + as_extended: bool = True, + **kwargs: Any, + ) -> DataWorkflow: + """HTTP POST request returning a workflow over decoded response data.""" + return self.request_workflow("POST", endpoint, suffix=suffix, as_extended=as_extended, **kwargs) + + def put(self, endpoint: str, **kwargs: Any) -> httpx.Response: """HTTP PUT request.""" return self.request("PUT", endpoint, **kwargs) - def delete(self, endpoint: str, **kwargs) -> httpx.Response: + def put_data(self, endpoint: str, *, suffix: str | None = None, as_extended: bool = True, **kwargs: Any) -> Any: + """HTTP PUT request returning decoded response data.""" + return self.request_data("PUT", endpoint, suffix=suffix, as_extended=as_extended, **kwargs) + + def put_workflow( + self, + endpoint: str, + *, + suffix: str | None = None, + as_extended: bool = True, + **kwargs: Any, + ) -> DataWorkflow: + """HTTP PUT request returning a workflow over decoded response data.""" + return self.request_workflow("PUT", endpoint, suffix=suffix, as_extended=as_extended, **kwargs) + + def delete(self, endpoint: str, **kwargs: Any) -> httpx.Response: """HTTP DELETE request.""" return self.request("DELETE", endpoint, **kwargs) - def patch(self, endpoint: str, **kwargs) -> httpx.Response: + def delete_data(self, endpoint: str, *, suffix: str | None = None, as_extended: bool = True, **kwargs: Any) -> Any: + """HTTP DELETE request returning decoded response data.""" + return self.request_data("DELETE", endpoint, suffix=suffix, as_extended=as_extended, **kwargs) + + def delete_workflow( + self, + endpoint: str, + *, + suffix: str | None = None, + as_extended: bool = True, + **kwargs: Any, + ) -> DataWorkflow: + """HTTP DELETE request returning a workflow over decoded response data.""" + return self.request_workflow("DELETE", endpoint, suffix=suffix, as_extended=as_extended, **kwargs) + + def patch(self, endpoint: str, **kwargs: Any) -> httpx.Response: """HTTP PATCH request.""" return self.request("PATCH", endpoint, **kwargs) + def patch_data(self, endpoint: str, *, suffix: str | None = None, as_extended: bool = True, **kwargs: Any) -> Any: + """HTTP PATCH request returning decoded response data.""" + return self.request_data("PATCH", endpoint, suffix=suffix, as_extended=as_extended, **kwargs) + + def patch_workflow( + self, + endpoint: str, + *, + suffix: str | None = None, + as_extended: bool = True, + **kwargs: Any, + ) -> DataWorkflow: + """HTTP PATCH request returning a workflow over decoded response data.""" + return self.request_workflow("PATCH", endpoint, suffix=suffix, as_extended=as_extended, **kwargs) + # ------------------------------------------------------------------------- # File Downloads # ------------------------------------------------------------------------- @@ -331,7 +642,7 @@ def download(self, url: str, output_path: str) -> int: def register_tool( self, - func: Callable, + func: Callable[..., Any], name: str | None = None, description: str | None = None, schema: builtins.type[BaseModel] | None = None, @@ -357,9 +668,9 @@ def get_tools(self) -> list[StructuredTool]: """ try: from langchain_core.tools import StructuredTool - except ImportError: - self.logger.warning("langchain-core not installed, returning empty tools list") - return [] + except ImportError as e: + msg = "langchain-core is required for LangChain tools. Install with: pip install extended-data[langchain]" + raise ImportError(msg) from e tools = [] for name, func in self._tool_functions.items(): @@ -376,21 +687,22 @@ def get_tools(self) -> list[StructuredTool]: # AI Tool Definition Helpers # ------------------------------------------------------------------------- - def get_ai_tool_definitions(self) -> list[dict[str, Any]]: + def get_ai_tool_definitions(self) -> ExtendedList[ExtendedDict]: """Get tool definitions in Vercel AI SDK-compatible format. Returns: - List of AI tool definition dicts + Extended list of AI tool definition payloads. """ import inspect from extended_data.connectors.ai_tools import get_pydantic_schema + from extended_data.containers import to_builtin definitions = [] for name, func in self._tool_functions.items(): # Use Pydantic schema if available if name in self._tool_schemas: - input_schema = get_pydantic_schema(self._tool_schemas[name]) + input_schema = to_builtin(get_pydantic_schema(self._tool_schemas[name])) else: # Fallback to inspect-based schema generation sig = inspect.signature(func) @@ -427,7 +739,7 @@ def get_ai_tool_definitions(self) -> list[dict[str, Any]]: } ) - return definitions + return self.extend_result(definitions) def handle_ai_tool_call(self, name: str, arguments: dict[str, Any]) -> Any: """Handle an AI tool call. @@ -440,8 +752,8 @@ def handle_ai_tool_call(self, name: str, arguments: dict[str, Any]) -> Any: Tool result """ if name not in self._tool_functions: - msg = f"Unknown tool: {name}" + msg = f"Unknown tool: {redact_sensitive_text(name)}" raise ValueError(msg) func = self._tool_functions[name] - return func(**arguments) + return self.extend_result(func(**arguments)) diff --git a/src/extended_data/connectors/cli.py b/src/extended_data/connectors/cli.py index d57b961..162df52 100644 --- a/src/extended_data/connectors/cli.py +++ b/src/extended_data/connectors/cli.py @@ -4,54 +4,61 @@ using the central registry for discovery. Usage: - # List available connectors + # List connector catalog entries extended-data list + extended-data list --category cloud + extended-data list --capability repositories - # Call any connector method + # Call any connector data method extended-data call [--arg value ...] - # Interactive mode - extended-data shell - # Start MCP server extended-data mcp - - # Specific connector shortcuts (if implemented) - extended-data jules sources - extended-data cursor agents """ from __future__ import annotations import argparse -import json -import os import sys +from collections.abc import Mapping, Sequence from typing import Any from extended_data.connectors.registry import ( get_connector, get_connector_class, + get_connector_info, list_connector_info, + list_connectors_by_capability, + list_connectors_by_category, ) +from extended_data.connectors.surface import connector_data_methods, is_connector_data_method +from extended_data.containers import ExtendedList +from extended_data.containers.factory import to_builtin +from extended_data.io import wrap_raw_data_for_export +from extended_data.io.files import decode_file +from extended_data.primitives.formats.errors import DataDecodeError +from extended_data.primitives.redaction import redact_sensitive_text def _json_output(data: Any) -> str: """Format data as JSON for output.""" + data = to_builtin(data) if hasattr(data, "model_dump"): data = data.model_dump() - elif hasattr(data, "__iter__") and not isinstance(data, (str, dict)): + elif isinstance(data, Mapping): + data = dict(data) + elif hasattr(data, "__iter__") and not isinstance(data, (str, bytes, bytearray)): data = [d.model_dump() if hasattr(d, "model_dump") else d for d in data] - return json.dumps(data, indent=2, default=str) + return wrap_raw_data_for_export(data, allow_encoding="json", indent_2=True, default=str) def _parse_arg_value(value: str) -> Any: """Parse a CLI argument value, attempting JSON decode.""" # Try JSON first try: - return json.loads(value) - except json.JSONDecodeError: + return decode_file(value, suffix="json", as_extended=False) + except DataDecodeError: pass # Try common conversions @@ -73,36 +80,90 @@ def _parse_arg_value(value: str) -> Any: return value +def _format_list(values: list[Any] | tuple[Any, ...] | ExtendedList[Any] | None) -> str: + """Format a list-like metadata field for CLI output.""" + if not values: + return "-" + return ", ".join(str(value) for value in values) + + +def _write_stdout(message: str) -> None: + """Write one CLI output line.""" + sys.stdout.write(f"{redact_sensitive_text(message)}\n") + + +def _write_stderr(message: str) -> None: + """Write one CLI error line.""" + sys.stderr.write(f"{redact_sensitive_text(message)}\n") + + +def _filter_connector_info(args: argparse.Namespace) -> ExtendedList[Any]: + """Return connector catalog entries filtered by CLI flags.""" + include_unavailable = not getattr(args, "available_only", False) + info = list_connector_info(include_unavailable=include_unavailable) + names: set[str] | None = None + + if category := getattr(args, "category", None): + names = { + str(connector["name"]) + for connector in list_connectors_by_category(category, include_unavailable=include_unavailable) + } + + if capability := getattr(args, "capability", None): + capability_names = { + str(connector["name"]) + for connector in list_connectors_by_capability(capability, include_unavailable=include_unavailable) + } + names = capability_names if names is None else names & capability_names + + if names is None: + return info + + return ExtendedList(connector for connector in info if str(connector["name"]) in names) + + # ============================================================================= # Commands # ============================================================================= def cmd_list(args: argparse.Namespace) -> int: - """List available connectors.""" - info = list_connector_info() + """List connector catalog entries.""" + info = _filter_connector_info(args) if args.json: + _write_stdout(_json_output(info)) return 0 + _write_stdout(f"{'name':<18} {'status':<11} {'category':<16} {'capabilities':<34} {'extra':<10} install") for c in info: - env = c.get("api_key_env") or "-" - "✓" if os.environ.get(env) else " " + status = "available" if c["available"] else "missing" + name = str(c["name"]) + category = str(c.get("category") or "-") + capabilities = _format_list(c.get("capabilities")) + extra = str(c.get("extra") or "-") + install = str(c.get("install") or "-") + _write_stdout(f"{name:<18} {status:<11} {category:<16} {capabilities:<34} {extra:<10} {install}") return 0 def cmd_call(args: argparse.Namespace) -> int: - """Call a connector method.""" + """Call a connector data method.""" connector_name = args.connector method_name = args.method # Parse extra arguments kwargs = {} + json_output = bool(getattr(args, "json", False)) extra = args.extra or [] i = 0 while i < len(extra): arg = extra[i] + if arg == "--json": + json_output = True + i += 1 + continue if arg.startswith("--"): key = arg[2:].replace("-", "_") if i + 1 < len(extra) and not extra[i + 1].startswith("--"): @@ -115,38 +176,57 @@ def cmd_call(args: argparse.Namespace) -> int: i += 1 try: + cls = get_connector_class(connector_name) + class_method = getattr(cls, method_name, None) + if not is_connector_data_method(class_method): + _write_stderr(f"Connector {connector_name!r} has no exposed data method {method_name!r}") + return 1 + connector = get_connector(connector_name) method = getattr(connector, method_name, None) - if method is None: + if method is None or not callable(method): + _write_stderr(f"Connector {connector_name!r} has no callable method {method_name!r}") return 1 - method(**kwargs) + result = method(**kwargs) + if result is not None: + if json_output: + _write_stdout(_json_output(result)) + elif isinstance(result, str): + _write_stdout(result) + else: + _write_stdout(_json_output(result)) return 0 - except Exception: + except Exception as e: + _write_stderr(redact_sensitive_text(e, values=kwargs.values())) return 1 def cmd_methods(args: argparse.Namespace) -> int: - """List methods for a connector.""" + """List connector data methods.""" connector_name = args.connector try: cls = get_connector_class(connector_name) - except ValueError: + except (ImportError, ValueError) as e: + _write_stderr(str(e)) return 1 - for name in sorted(dir(cls)): - if name.startswith("_"): - continue - attr = getattr(cls, name, None) - if not callable(attr) or isinstance(attr, type): - continue + methods: list[dict[str, str]] = [] + for name, attr in connector_data_methods(cls): + doc = attr.__doc__.split("\n")[0].strip()[:50] if attr.__doc__ else "No description" + methods.append({"name": name, "description": doc}) + + if getattr(args, "json", False): + _write_stdout(_json_output(methods)) + return 0 - # Get first line of docstring - if attr.__doc__: - attr.__doc__.split("\n")[0].strip()[:50] + for method in methods: + name = method["name"] + doc = method["description"] + _write_stdout(f" {name:<30} {doc}") return 0 @@ -160,12 +240,34 @@ def cmd_mcp(args: argparse.Namespace) -> int: def cmd_info(args: argparse.Namespace) -> int: """Show info about a specific connector.""" - from extended_data.connectors.registry import get_connector_info - try: - get_connector_info(args.connector) + info = get_connector_info(args.connector) + if args.json: + _write_stdout(_json_output(info)) + return 0 + + for key in ( + "name", + "available", + "source", + "category", + "capabilities", + "extra", + "install", + "requirements", + "missing", + "class", + "module", + "description", + "error", + ): + value = info.get(key) + if isinstance(value, list | tuple | ExtendedList): + value = _format_list(value) + _write_stdout(f"{key}: {value if value is not None else '-'}") return 0 - except ValueError: + except (ImportError, ValueError) as e: + _write_stderr(str(e)) return 1 @@ -174,7 +276,7 @@ def cmd_info(args: argparse.Namespace) -> int: # ============================================================================= -def main() -> int: +def main(argv: Sequence[str] | None = None) -> int: """Main CLI entry point.""" parser = argparse.ArgumentParser( prog="extended-data", @@ -182,8 +284,10 @@ def main() -> int: formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: - extended-data list # List all connectors - extended-data methods jules # List Jules methods + extended-data list # List connector catalog entries + extended-data list --category cloud # List cloud connectors + extended-data list --capability files # List connectors by capability + extended-data methods jules # List Jules data methods extended-data call jules list_sources # Call a method extended-data call cursor list_agents extended-data mcp # Start MCP server @@ -192,25 +296,31 @@ def main() -> int: subparsers = parser.add_subparsers(dest="command", help="Commands") # List command - list_parser = subparsers.add_parser("list", help="List available connectors") + list_parser = subparsers.add_parser("list", help="List connector catalog entries") list_parser.add_argument("--json", action="store_true", help="JSON output") + list_parser.add_argument("--available-only", action="store_true", help="Hide connectors with missing extras") + list_parser.add_argument("--category", help="Filter by catalog category") + list_parser.add_argument("--capability", help="Filter by catalog capability") list_parser.set_defaults(func=cmd_list) # Methods command - methods_parser = subparsers.add_parser("methods", help="List methods for a connector") + methods_parser = subparsers.add_parser("methods", help="List connector data methods") methods_parser.add_argument("connector", help="Connector name") + methods_parser.add_argument("--json", action="store_true", help="JSON output") methods_parser.set_defaults(func=cmd_methods) # Info command info_parser = subparsers.add_parser("info", help="Show connector info") info_parser.add_argument("connector", help="Connector name") + info_parser.add_argument("--json", action="store_true", help="JSON output") info_parser.set_defaults(func=cmd_info) # Call command - call_parser = subparsers.add_parser("call", help="Call a connector method") + call_parser = subparsers.add_parser("call", help="Call a connector data method") + call_parser.add_argument("--json", action="store_true", help="JSON output") call_parser.add_argument("connector", help="Connector name") call_parser.add_argument("method", help="Method name") - call_parser.add_argument("extra", nargs="*", help="Method arguments (--arg value)") + call_parser.add_argument("extra", nargs=argparse.REMAINDER, help="Method arguments (--arg value)") call_parser.set_defaults(func=cmd_call) # MCP command @@ -218,7 +328,7 @@ def main() -> int: mcp_parser.set_defaults(func=cmd_mcp) # Parse and execute - args = parser.parse_args() + args = parser.parse_args(argv) if not args.command: parser.print_help() @@ -229,7 +339,8 @@ def main() -> int: return args.func(args) except KeyboardInterrupt: return 130 - except Exception: + except Exception as e: + _write_stderr(str(e)) return 1 parser.print_help() diff --git a/src/extended_data/connectors/cloud_params.py b/src/extended_data/connectors/cloud_params.py index 5718bad..63668d8 100644 --- a/src/extended_data/connectors/cloud_params.py +++ b/src/extended_data/connectors/cloud_params.py @@ -16,7 +16,8 @@ from typing import Any -from extended_data import is_nothing, lower_first_char, upper_first_char +from extended_data.containers import ExtendedDict, extend_data +from extended_data.primitives import is_nothing, lower_first_char, upper_first_char def get_cloud_call_params( @@ -26,7 +27,7 @@ def get_cloud_call_params( first_letter_to_lower: bool = False, first_letter_to_upper: bool = False, **kwargs: Any, -) -> dict[str, Any]: +) -> ExtendedDict: """Build a parameter dictionary for cloud API calls. This function creates properly formatted parameter dictionaries for @@ -42,7 +43,7 @@ def get_cloud_call_params( **kwargs: Additional parameters to include. Returns: - A dictionary of parameters ready for the cloud API call. + An extended dictionary of parameters ready for the cloud API call. Examples: >>> get_cloud_call_params(max_results=100, NextToken="abc123") @@ -58,7 +59,7 @@ def get_cloud_call_params( params["MaxResults"] = max_results if not first_letter_to_lower and not first_letter_to_upper: - return params + return extend_data(params) if first_letter_to_lower: params = {lower_first_char(k): v for k, v in params.items()} @@ -66,10 +67,10 @@ def get_cloud_call_params( if first_letter_to_upper: params = {upper_first_char(k): v for k, v in params.items()} - return params + return extend_data(params) -def get_aws_call_params(max_results: int | None = 100, **kwargs: Any) -> dict[str, Any]: +def get_aws_call_params(max_results: int | None = 100, **kwargs: Any) -> ExtendedDict: """Build parameters for AWS API calls. AWS APIs typically use PascalCase keys (e.g., MaxResults, NextToken). @@ -80,7 +81,7 @@ def get_aws_call_params(max_results: int | None = 100, **kwargs: Any) -> dict[st **kwargs: Additional parameters (will be PascalCased). Returns: - Parameter dictionary with PascalCase keys. + Extended parameter dictionary with PascalCase keys. Examples: >>> get_aws_call_params(NextToken="abc") @@ -94,7 +95,7 @@ def get_aws_call_params(max_results: int | None = 100, **kwargs: Any) -> dict[st def get_google_call_params( max_results: int | None = 200, no_max_results: bool = False, **kwargs: Any -) -> dict[str, Any]: +) -> ExtendedDict: """Build parameters for Google Cloud API calls. Google APIs typically use camelCase keys (e.g., maxResults, pageToken). @@ -106,7 +107,7 @@ def get_google_call_params( **kwargs: Additional parameters (will be camelCased). Returns: - Parameter dictionary with camelCase keys. + Extended parameter dictionary with camelCase keys. Examples: >>> get_google_call_params(pageToken="xyz") diff --git a/src/extended_data/connectors/connectors.py b/src/extended_data/connectors/connectors.py index 272164b..6bfc6a1 100644 --- a/src/extended_data/connectors/connectors.py +++ b/src/extended_data/connectors/connectors.py @@ -1,4 +1,4 @@ -"""ConnectorFabric - Public API with caching like TerraformDataSource.""" +"""ConnectorFabric - cached connector access for Extended Data.""" from __future__ import annotations @@ -6,17 +6,71 @@ from typing import TYPE_CHECKING, Any -from extended_data import get_default_dict, get_unique_signature, make_hashable - # Import zoom directly (no extra deps) -from extended_data.connectors.registry import get_connector_class +from extended_data.connectors.base import ConnectorBase +from extended_data.connectors.registry import ( + get_connector_class, +) +from extended_data.connectors.registry import ( + get_connector_info as get_registered_connector_info, +) +from extended_data.connectors.registry import ( + list_available_connectors as list_registered_available_connectors, +) +from extended_data.connectors.registry import ( + list_connector_capabilities as list_registered_connector_capabilities, +) +from extended_data.connectors.registry import ( + list_connector_categories as list_registered_connector_categories, +) +from extended_data.connectors.registry import ( + list_connector_info as list_registered_connector_info, +) +from extended_data.connectors.registry import ( + list_connectors as list_registered_connectors, +) +from extended_data.connectors.registry import ( + list_connectors_by_capability as list_registered_connectors_by_capability, +) +from extended_data.connectors.registry import ( + list_connectors_by_category as list_registered_connectors_by_category, +) from extended_data.connectors.zoom import ZoomConnector +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString from extended_data.inputs import InputProvider from extended_data.logging import Logging +from extended_data.primitives import get_default_dict, get_unique_signature, make_hashable + + +_SENSITIVE_CACHE_KEY_PARTS = ( + "api_key", + "authorization", + "client_secret", + "credential", + "password", + "secret", + "token", +) + + +def _is_sensitive_cache_field(name: str) -> bool: + """Return whether a cache-key field name usually carries secret material.""" + normalized = name.lower().replace("-", "_") + return any(part in normalized for part in _SENSITIVE_CACHE_KEY_PARTS) + + +def _cache_safe_value(name: str, value: Any) -> Any: + """Return cache-key material without storing raw secret values.""" + hashable_value = make_hashable(value) + if value is None or not _is_sensitive_cache_field(name): + return hashable_value + + digest = hashlib.sha256(repr(hashable_value).encode()).hexdigest() + return ("sha256", digest) # Optional connectors - imported lazily when methods are called -# This allows the package to be imported without all vendor SDKs installed +# This allows the package to be imported without all optional SDKs installed if TYPE_CHECKING: import boto3 @@ -35,8 +89,8 @@ class ConnectorFabric(InputProvider): """Public API for extended data connectors with client caching. - This class provides cached access to all extended data connectors, similar to - how TerraformDataSource works in terraform-modules libraries. + This class provides cached access to registered connectors while + sharing input snapshots, lifecycle logging, and data normalization. Usage: vc = ConnectorFabric() @@ -55,36 +109,78 @@ class ConnectorFabric(InputProvider): def __init__( self, logger: Logging | None = None, - **kwargs, - ): + **kwargs: Any, + ) -> None: super().__init__(**kwargs) self.logging = logger or Logging(logger_name=get_unique_signature(self)) self.logger = self.logging.logger # Client cache - nested dict for different client types and their params - self._client_cache: dict[str, dict[Any, Any]] = get_default_dict(levels=2) + self._client_cache: dict[str, dict[frozenset[tuple[str, Any]], Any]] = get_default_dict(levels=2) - def _get_cache_key(self, **kwargs) -> frozenset: + def _get_cache_key(self, **kwargs: Any) -> frozenset[tuple[str, Any]]: """Generate a hashable cache key from kwargs.""" - hashable_kwargs = {k: make_hashable(v) for k, v in kwargs.items()} + hashable_kwargs = {k: _cache_safe_value(k, v) for k, v in kwargs.items()} return frozenset(hashable_kwargs.items()) - def _get_cached_client(self, client_type: str, **kwargs) -> Any | None: + def _get_cached_client(self, client_type: str, **kwargs: Any) -> Any | None: """Retrieve a client from cache.""" cache_key = self._get_cache_key(**kwargs) return self._client_cache[client_type].get(cache_key) - def _set_cached_client(self, client_type: str, client: Any, **kwargs) -> None: + def _set_cached_client(self, client_type: str, client: Any, **kwargs: Any) -> None: """Store a client in cache.""" cache_key = self._get_cache_key(**kwargs) self._client_cache[client_type][cache_key] = client - def get_connector(self, name: str, **kwargs: Any) -> Any: + def list_connectors(self) -> ExtendedList[ExtendedString]: + """List connector catalog names.""" + return list_registered_connectors() + + def list_available_connectors(self) -> ExtendedList[ExtendedString]: + """List connector names available in the current environment.""" + return list_registered_available_connectors() + + def list_connector_info(self, *, include_unavailable: bool = True) -> ExtendedList[ExtendedDict]: + """List connector catalog metadata.""" + return list_registered_connector_info(include_unavailable=include_unavailable) + + def list_connector_categories(self, *, include_unavailable: bool = True) -> ExtendedList[ExtendedString]: + """List connector catalog categories.""" + return list_registered_connector_categories(include_unavailable=include_unavailable) + + def list_connector_capabilities(self, *, include_unavailable: bool = True) -> ExtendedList[ExtendedString]: + """List connector catalog capabilities.""" + return list_registered_connector_capabilities(include_unavailable=include_unavailable) + + def list_connectors_by_category( + self, + category: str, + *, + include_unavailable: bool = True, + ) -> ExtendedList[ExtendedDict]: + """List connector catalog metadata for a category.""" + return list_registered_connectors_by_category(category, include_unavailable=include_unavailable) + + def list_connectors_by_capability( + self, + capability: str, + *, + include_unavailable: bool = True, + ) -> ExtendedList[ExtendedDict]: + """List connector catalog metadata for a capability.""" + return list_registered_connectors_by_capability(capability, include_unavailable=include_unavailable) + + def get_connector_info(self, name: str, *, include_unavailable: bool = True) -> ExtendedDict: + """Get catalog metadata for one connector.""" + return get_registered_connector_info(name, include_unavailable=include_unavailable) + + def get_connector(self, name: str, **kwargs: Any) -> ConnectorBase: """Get a cached connector instance by registry name. The connector receives the fabric's shared inputs and logger unless explicit values are passed in ``kwargs``. This is the generic path for - vendor adapters that are registered through entry points or built-ins. + connectors that are registered through entry points or built-ins. """ connector_name = name.strip().lower() cache_kwargs = {"name": connector_name, **kwargs} @@ -139,7 +235,7 @@ def get_aws_client( execution_role_arn: str | None = None, role_session_name: str | None = None, config: Config | None = None, - **client_args, + **client_args: Any, ) -> boto3.client: """Get a cached boto3 client.""" execution_role_arn = execution_role_arn or self.get_input("EXECUTION_ROLE_ARN", required=False) @@ -177,7 +273,7 @@ def get_aws_resource( execution_role_arn: str | None = None, role_session_name: str | None = None, config: Config | None = None, - **resource_args, + **resource_args: Any, ) -> ServiceResource: """Get a cached boto3 resource.""" execution_role_arn = execution_role_arn or self.get_input("EXECUTION_ROLE_ARN", required=False) @@ -321,9 +417,12 @@ def get_google_client( # For caching, use a hash to avoid exposing sensitive data cache_sa = hashlib.sha256(str(service_account_info).encode()).hexdigest()[:16] if service_account_info else None + cache_scopes = tuple(scopes) if scopes else None + cached = self._get_cached_client( "google", service_account=cache_sa, + scopes=cache_scopes, subject=subject, ) if cached: @@ -340,6 +439,7 @@ def get_google_client( "google", connector, service_account=cache_sa, + scopes=cache_scopes, subject=subject, ) return connector diff --git a/src/extended_data/connectors/cursor/__init__.py b/src/extended_data/connectors/cursor/__init__.py index e5f5e8b..fd1976e 100644 --- a/src/extended_data/connectors/cursor/__init__.py +++ b/src/extended_data/connectors/cursor/__init__.py @@ -23,6 +23,7 @@ import os import re +from collections.abc import Iterable, Mapping from dataclasses import dataclass from datetime import datetime from enum import Enum @@ -31,10 +32,12 @@ import httpx -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, ValidationError -from extended_data.connectors.base import VendorConnectorBase +from extended_data.connectors.base import ConnectorBase +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString, to_builtin from extended_data.logging import Logging +from extended_data.primitives.redaction import redact_sensitive_text if TYPE_CHECKING: @@ -271,7 +274,7 @@ def validate_webhook_url(url: str) -> None: try: parsed = urlparse(url) except Exception as e: - raise CursorValidationError(f"Webhook URL is not a valid URL: {e}") from e + raise CursorValidationError(f"Webhook URL is not a valid URL: {_safe_cursor_text(e, url)}") from None # Security: Only allow HTTPS if parsed.scheme != "https": @@ -292,22 +295,43 @@ def validate_webhook_url(url: str) -> None: raise CursorValidationError(msg) -def sanitize_error(error: Any) -> str: +def _iter_diagnostic_values(values: Iterable[Any]) -> Iterable[Any]: + """Yield scalar values from nested diagnostic context.""" + for value in values: + if value is None: + continue + if isinstance(value, Mapping): + yield from _iter_diagnostic_values(value.values()) + elif isinstance(value, (str, bytes)): + yield value + elif isinstance(value, Iterable): + yield from _iter_diagnostic_values(value) + else: + yield value + + +def _safe_cursor_text(value: Any, *sensitive_values: Any) -> str: + """Redact secrets and caller-provided Cursor identifiers from diagnostics.""" + return redact_sensitive_text(value, values=_iter_diagnostic_values(sensitive_values)) + + +def _safe_cursor_ref(value: Any) -> str: + """Redact a single Cursor resource reference for diagnostic logs.""" + return _safe_cursor_text(value, value) + + +def sanitize_error(error: Any, *, values: Iterable[Any] | None = None) -> str: """Sanitize error messages to prevent sensitive data leakage. Args: error: The error to sanitize. + values: Explicit caller-provided values that must not appear in diagnostics. Returns: Sanitized error message string. """ message = str(error) if not isinstance(error, str) else error - # Remove potential API keys, tokens, or sensitive patterns - message = re.sub(r"Bearer\s+[a-zA-Z0-9._-]+", "Bearer [REDACTED]", message, flags=re.IGNORECASE) - message = re.sub( - r"api[_-]?key[=:]\s*[\"']?[a-zA-Z0-9._-]+[\"']?", "api_key=[REDACTED]", message, flags=re.IGNORECASE - ) - return re.sub(r"token[=:]\s*[\"']?[a-zA-Z0-9._-]+[\"']?", "token=[REDACTED]", message, flags=re.IGNORECASE) + return redact_sensitive_text(message, values=_iter_diagnostic_values(values or ())) # ============================================================================= @@ -315,7 +339,7 @@ def sanitize_error(error: Any) -> str: # ============================================================================= -class CursorConnector(VendorConnectorBase): +class CursorConnector(ConnectorBase): """Cursor Background Agent API connector. Provides HTTP client access to Cursor's agent management API for spawning, @@ -344,8 +368,8 @@ def __init__( base_url: str | None = None, timeout: float = DEFAULT_TIMEOUT, logger: Logging | None = None, - **kwargs, - ): + **kwargs: Any, + ) -> None: super().__init__(api_key=api_key, base_url=base_url, logger=logger, timeout=timeout, **kwargs) # Validate API key @@ -353,7 +377,7 @@ def __init__( msg = "CURSOR_API_KEY is required. Set it in environment or pass to constructor." raise CursorError(msg) - self.logger.info(f"Initialized CursorConnector with base URL: {self._base_url}") + self.logger.info(f"Initialized CursorConnector with base URL: {_safe_cursor_ref(self._base_url)}") @staticmethod def is_available() -> bool: @@ -369,7 +393,7 @@ def _request_api( endpoint: str, method: str = "GET", json_body: dict[str, Any] | None = None, - ) -> dict[str, Any] | None: + ) -> ExtendedDict | None: """Make an HTTP request to the Cursor API. Args: @@ -399,24 +423,77 @@ def _request_api( if not text or not text.strip(): return None - return response.json() + decoded = self.decode_response(response, suffix="json", as_extended=True) + if decoded is None: + return None + if not isinstance(decoded, Mapping): + raise self._unexpected_response_error("_request_api", decoded, endpoint, json_body) + return ExtendedDict(decoded) - except httpx.TimeoutException as e: - raise CursorAPIError(f"Request timeout after {self._timeout}s") from e + except httpx.TimeoutException: + raise CursorAPIError(f"Request timeout after {self._timeout}s") from None except Exception as e: if isinstance(e, CursorAPIError): raise - raise CursorAPIError(sanitize_error(str(e))) from e + raise CursorAPIError(sanitize_error(str(e), values=[endpoint, json_body])) from None + + @staticmethod + def _model_payload(model: BaseModel) -> dict[str, Any]: + """Serialize a Cursor model into JSON-compatible API field names.""" + payload = model.model_dump(mode="json") + if isinstance(model, Agent) and payload.get("error"): + payload["error"] = sanitize_error(payload["error"]) + return payload + + @staticmethod + def _unexpected_response_error(operation: str, data: Any, *sensitive_values: Any) -> CursorAPIError: + """Build a redacted malformed-response error.""" + return CursorAPIError( + f"Unexpected Cursor response for {operation}: {_safe_cursor_text(data, *sensitive_values)}" + ) + + def _parse_model_response( + self, + data: Any, + model_type: type[BaseModel], + operation: str, + *sensitive_values: Any, + ) -> dict[str, Any]: + """Validate one Cursor response model and return a JSON payload.""" + model_data = to_builtin(data) + try: + return self._model_payload(model_type.model_validate(model_data)) + except ValidationError: + raise self._unexpected_response_error(operation, data, *sensitive_values) from None + + def _parse_model_list( + self, + data: Any, + key: str, + model_type: type[BaseModel], + operation: str, + *sensitive_values: Any, + ) -> list[dict[str, Any]]: + """Validate a Cursor response list and return JSON payloads.""" + model_data = to_builtin(data) + items = model_data.get(key, []) if isinstance(model_data, Mapping) else None + if not isinstance(items, list): + raise self._unexpected_response_error(operation, data, *sensitive_values) + + try: + return [self._model_payload(model_type.model_validate(item)) for item in items] + except ValidationError: + raise self._unexpected_response_error(operation, data, *sensitive_values) from None # ========================================================================= # Agent Operations # ========================================================================= - def list_agents(self) -> list[Agent]: + def list_agents(self) -> ExtendedList[ExtendedDict]: """List all agents. Returns: - List of Agent objects. + List of agent payload dictionaries. Raises: CursorAPIError: If the API request fails. @@ -424,54 +501,62 @@ def list_agents(self) -> list[Agent]: self.logger.info("Listing agents") data = self._request_api("/agents") if not data: - return [] + return self.extend_result([]) - agents_data = data.get("agents", []) - return [Agent.model_validate(a) for a in agents_data] + return self.extend_result(self._parse_model_list(data, "agents", Agent, "list_agents")) - def get_agent_status(self, agent_id: str) -> Agent: + def get_agent_status(self, agent_id: str) -> ExtendedDict: """Get status of a specific agent. Args: agent_id: The agent identifier. Returns: - Agent object with current status. + Agent payload dictionary with current status. Raises: CursorValidationError: If agent_id is invalid. CursorAPIError: If the API request fails or returns empty response. """ validate_agent_id(agent_id) - self.logger.info(f"Getting status for agent: {agent_id}") + self.logger.info(f"Getting status for agent: {_safe_cursor_ref(agent_id)}") data = self._request_api(f"/agents/{agent_id}") if not data: - raise CursorAPIError(f"Empty response when getting agent status for {agent_id}") - return Agent.model_validate(data) + raise CursorAPIError(f"Empty response when getting agent status for {_safe_cursor_ref(agent_id)}") + return self.extend_result(self._parse_model_response(data, Agent, "get_agent_status", agent_id)) - def get_agent_conversation(self, agent_id: str) -> Conversation: + def get_agent_conversation(self, agent_id: str) -> ExtendedDict: """Get conversation history for an agent. Args: agent_id: The agent identifier. Returns: - Conversation object with message history. + Conversation payload dictionary with message history. Raises: CursorValidationError: If agent_id is invalid. CursorAPIError: If the API request fails. """ validate_agent_id(agent_id) - self.logger.info(f"Getting conversation for agent: {agent_id}") + self.logger.info(f"Getting conversation for agent: {_safe_cursor_ref(agent_id)}") data = self._request_api(f"/agents/{agent_id}/conversation") if not data: - return Conversation(agent_id=agent_id, messages=[]) + return self.extend_result(self._model_payload(Conversation(agent_id=agent_id, messages=[]))) + + plain_data = to_builtin(data) + message_data = plain_data.get("messages", []) if isinstance(plain_data, Mapping) else None + if not isinstance(message_data, list): + raise self._unexpected_response_error("get_agent_conversation", data, agent_id) - messages = [ConversationMessage.model_validate(m) for m in data.get("messages", [])] - return Conversation(agent_id=agent_id, messages=messages) + try: + messages = [ConversationMessage.model_validate(message) for message in message_data] + conversation = Conversation(agent_id=agent_id, messages=messages) + except ValidationError: + raise self._unexpected_response_error("get_agent_conversation", data, agent_id) from None + return self.extend_result(self._model_payload(conversation)) def launch_agent( self, @@ -485,7 +570,7 @@ def launch_agent( skip_reviewer_request: bool = False, webhook_url: str | None = None, webhook_secret: str | None = None, - ) -> Agent: + ) -> ExtendedDict: """Launch a new agent. Args: @@ -501,7 +586,7 @@ def launch_agent( webhook_secret: Webhook secret for signature verification. Returns: - The launched Agent object. + The launched agent payload dictionary. Raises: CursorValidationError: If inputs are invalid. @@ -517,7 +602,7 @@ def launch_agent( if webhook_url: validate_webhook_url(webhook_url) - self.logger.info(f"Launching agent for repository: {repository}") + self.logger.info(f"Launching agent for repository: {_safe_cursor_ref(repository)}") body: dict[str, Any] = { "prompt": { @@ -552,11 +637,22 @@ def launch_agent( webhook["secret"] = webhook_secret body["webhook"] = webhook - data = self._request_api("/agents", method="POST", json_body=body) + data = self._request_api("/agents", method="POST", json_body=to_builtin(body)) if not data: msg = "Empty response when launching agent" raise CursorAPIError(msg) - return Agent.model_validate(data) + return self.extend_result( + self._parse_model_response( + data, + Agent, + "launch_agent", + prompt_text, + repository, + ref, + branch_name, + webhook_url, + ) + ) def add_followup(self, agent_id: str, prompt_text: str) -> None: """Send a follow-up message to an agent. @@ -572,7 +668,7 @@ def add_followup(self, agent_id: str, prompt_text: str) -> None: validate_agent_id(agent_id) validate_prompt_text(prompt_text) - self.logger.info(f"Adding follow-up to agent: {agent_id}") + self.logger.info(f"Adding follow-up to agent: {_safe_cursor_ref(agent_id)}") self._request_api( f"/agents/{agent_id}/followup", @@ -584,11 +680,11 @@ def add_followup(self, agent_id: str, prompt_text: str) -> None: # Repository Operations # ========================================================================= - def list_repositories(self) -> list[Repository]: + def list_repositories(self) -> ExtendedList[ExtendedDict]: """List available repositories. Returns: - List of Repository objects. + List of repository payload dictionaries. Raises: CursorAPIError: If the API request fails. @@ -596,16 +692,15 @@ def list_repositories(self) -> list[Repository]: self.logger.info("Listing repositories") data = self._request_api("/repositories") if not data: - return [] + return self.extend_result([]) - repos_data = data.get("repositories", []) - return [Repository.model_validate(r) for r in repos_data] + return self.extend_result(self._parse_model_list(data, "repositories", Repository, "list_repositories")) # ========================================================================= # Model Operations # ========================================================================= - def list_models(self) -> list[str]: + def list_models(self) -> ExtendedList[ExtendedString]: """List available models. Returns: @@ -617,6 +712,10 @@ def list_models(self) -> list[str]: self.logger.info("Listing models") data = self._request_api("/models") if not data: - return [] + return self.extend_result([]) - return data.get("models", []) + plain_data = to_builtin(data) + models = plain_data.get("models", []) if isinstance(plain_data, Mapping) else None + if not isinstance(models, list) or any(not isinstance(model, str) for model in models): + raise self._unexpected_response_error("list_models", data) + return self.extend_result(models) diff --git a/src/extended_data/connectors/cursor/tools.py b/src/extended_data/connectors/cursor/tools.py index d29dc0d..2a3e94f 100644 --- a/src/extended_data/connectors/cursor/tools.py +++ b/src/extended_data/connectors/cursor/tools.py @@ -10,6 +10,24 @@ from pydantic import BaseModel, Field +from extended_data.connectors.ai_tools import raise_unknown_tool_framework +from extended_data.containers import ExtendedDict, extend_data + + +def _error_value(error: Any) -> Any: + """Return a sanitized error value while preserving empty values.""" + if not error: + return error + + from extended_data.connectors.cursor import sanitize_error + + return sanitize_error(error) + + +def _state_value(state: Any) -> Any: + """Return enum values for tool payloads while preserving plain strings.""" + return getattr(state, "value", state) + class LaunchAgentSchema(BaseModel): """Pydantic schema for the cursor_launch_agent tool.""" @@ -31,7 +49,7 @@ def cursor_launch_agent( repository: str, ref: str | None = None, branch_name: str | None = None, -) -> dict[str, Any]: +) -> ExtendedDict: """Launch a new Cursor coding agent. Args: @@ -53,14 +71,16 @@ def cursor_launch_agent( branch_name=branch_name, ) - return { - "agent_id": agent.id, - "state": agent.state, - "repository": agent.repository, - } + return extend_data( + { + "agent_id": agent.get("id", ""), + "state": _state_value(agent.get("state")), + "repository": agent.get("repository"), + } + ) -def cursor_get_agent_status(agent_id: str) -> dict[str, Any]: +def cursor_get_agent_status(agent_id: str) -> ExtendedDict: """Get the current status of a Cursor agent. Args: @@ -74,12 +94,14 @@ def cursor_get_agent_status(agent_id: str) -> dict[str, Any]: connector = CursorConnector() agent = connector.get_agent_status(agent_id) - return { - "agent_id": agent.id, - "state": agent.state, - "error": agent.error, - "pr_url": agent.pr_url, - } + return extend_data( + { + "agent_id": agent.get("id", ""), + "state": _state_value(agent.get("state")), + "error": _error_value(agent.get("error")), + "pr_url": agent.get("pr_url"), + } + ) TOOL_DEFINITIONS = [ @@ -100,30 +122,16 @@ def cursor_get_agent_status(agent_id: str) -> dict[str, Any]: def get_langchain_tools() -> list[Any]: """Get all Cursor tools as LangChain StructuredTools.""" - try: - from langchain_core.tools import StructuredTool - except ImportError as e: - msg = "langchain-core is required for LangChain tools." - raise ImportError(msg) from e - - return [ - StructuredTool.from_function( - func=defn["func"], - name=defn["name"], - description=defn["description"], - args_schema=defn.get("schema") or defn.get("args_schema"), - ) - for defn in TOOL_DEFINITIONS - ] + from extended_data.connectors.ai_tools import build_langchain_tools + + return build_langchain_tools(TOOL_DEFINITIONS) def get_crewai_tools() -> list[Any]: """Get all Cursor tools as CrewAI tools.""" - try: - from crewai.tools import tool as crewai_tool - except ImportError as e: - msg = "crewai is required for CrewAI tools." - raise ImportError(msg) from e + from extended_data.connectors._optional import get_crewai_tool_decorator + + crewai_tool = get_crewai_tool_decorator() tools = [] for defn in TOOL_DEFINITIONS: @@ -157,10 +165,10 @@ def get_tools(framework: str = "auto") -> list[Any]: return get_langchain_tools() if framework == "crewai": return get_crewai_tools() - if framework in ("strands", "functions"): + if framework == "strands": return get_strands_tools() - raise ValueError(f"Unknown framework: {framework}") + return raise_unknown_tool_framework(framework) __all__ = [ diff --git a/src/extended_data/connectors/github/__init__.py b/src/extended_data/connectors/github/__init__.py index f791fed..185b85a 100644 --- a/src/extended_data/connectors/github/__init__.py +++ b/src/extended_data/connectors/github/__init__.py @@ -2,32 +2,77 @@ from __future__ import annotations -import io import os +from collections.abc import Mapping, Sequence from copy import deepcopy from typing import Any -from github import Auth, Github -from github.GithubException import GithubException, UnknownObjectException -from python_graphql_client import GraphqlClient -from ruamel.yaml import YAML - -from extended_data import ( - decode_json, - decode_yaml, +from extended_data.connectors._optional import require_extra +from extended_data.connectors.base import ConnectorBase +from extended_data.connectors.github._diagnostics import safe_github_ref, safe_github_text +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString, ExtendedTuple +from extended_data.io import ( + decode_file, get_encoding_for_file_path, - is_nothing, wrap_raw_data_for_export, ) -from extended_data.connectors.base import VendorConnectorBase from extended_data.logging import Logging +from extended_data.primitives import is_nothing + + +Auth: Any = None +Github: Any = None +GraphqlClient: Any = None + + +class GitHubFallbackError(Exception): + """Fallback exception used until PyGithub is imported.""" + + +GithubException: Any = GitHubFallbackError +UnknownObjectException: Any = GitHubFallbackError + +FilePath = str | os.PathLike[str] -FilePath = str | bytes | os.PathLike[Any] +def _require_loaded(module: Any | None, module_name: str) -> Any: + if module is None: # pragma: no cover - defensive guard for loader invariants + raise RuntimeError(f"Failed to load optional GitHub dependency module: {module_name}") + return module -def get_github_api_error(exc: GithubException) -> str | None: + +def _load_github_sdk() -> None: + """Load GitHub SDK dependencies lazily so tool metadata remains importable.""" + global Auth, Github, GithubException, GraphqlClient, UnknownObjectException + + needs_github_module = Auth is None or Github is None + needs_exceptions = GithubException is GitHubFallbackError or UnknownObjectException is GitHubFallbackError + needs_graphql = GraphqlClient is None + + if needs_github_module or needs_exceptions or needs_graphql: + try: + github_module = require_extra("github", "github") if needs_github_module else None + github_exceptions = require_extra("github.GithubException", "github") if needs_exceptions else None + graphql_module = require_extra("python_graphql_client", "github") if needs_graphql else None + except ImportError as exc: + msg = "PyGithub is required for GitHubConnector. Install with: pip install extended-data[github]" + raise ImportError(msg) from exc + + if Auth is None: + Auth = _require_loaded(github_module, "github").Auth + if Github is None: + Github = _require_loaded(github_module, "github").Github + if GithubException is GitHubFallbackError: + GithubException = _require_loaded(github_exceptions, "github.GithubException").GithubException + if UnknownObjectException is GitHubFallbackError: + UnknownObjectException = _require_loaded(github_exceptions, "github.GithubException").UnknownObjectException + if GraphqlClient is None: + GraphqlClient = _require_loaded(graphql_module, "python_graphql_client").GraphqlClient + + +def get_github_api_error(exc: BaseException) -> str | None: """Extract error message from a GitHub exception.""" data = getattr(exc, "data", {}) return data.get("message", None) @@ -36,7 +81,7 @@ def get_github_api_error(exc: GithubException) -> str | None: DEFAULT_PER_PAGE = 100 -class GitHubConnector(VendorConnectorBase): +class GitHubConnector(ConnectorBase): """GitHub connector for repository operations.""" def __init__( @@ -47,9 +92,10 @@ def __init__( github_token: str | None = None, per_page: int = DEFAULT_PER_PAGE, logger: Logging | None = None, - **kwargs, - ): + **kwargs: Any, + ) -> None: super().__init__(logger=logger, **kwargs) + _load_github_sdk() self.GITHUB_OWNER = github_owner self.GITHUB_REPO = github_repo @@ -61,42 +107,51 @@ def __init__( self.repo = None if github_repo: + repo_ref = f"{self.GITHUB_OWNER}/{self.GITHUB_REPO}" try: - self.repo = self.git.get_repo(f"{self.GITHUB_OWNER}/{self.GITHUB_REPO}") - self.logger.info(f"Connecting to Git repository {self.GITHUB_OWNER}/{self.GITHUB_REPO}") + self.repo = self.git.get_repo(repo_ref) + self.logger.info(f"Connecting to Git repository {safe_github_ref(repo_ref)}") except UnknownObjectException: - self.logger.warning(f"Repository {self.GITHUB_OWNER}/{self.GITHUB_REPO} does not exist") + self.logger.warning(f"Repository {safe_github_ref(repo_ref)} does not exist") if github_branch is None and self.repo: - self.GITHUB_BRANCH = self.repo.default_branch + self.GITHUB_BRANCH: str | None = self.repo.default_branch else: self.GITHUB_BRANCH = github_branch self.graphql_client = GraphqlClient(endpoint="https://api.github.com/graphql") - def get_repository_branch(self, branch_name: str): + def get_repository_branch(self, branch_name: str) -> Any | None: """Get a repository branch by name.""" if self.repo is None: - self.logger.warning(f"Repository not set for {self.GITHUB_OWNER}, cannot get branch {branch_name}") + self.logger.warning( + f"Repository not set for {safe_github_ref(self.GITHUB_OWNER)}, " + f"cannot get branch {safe_github_ref(branch_name)}" + ) return None try: return self.repo.get_branch(branch_name) except UnknownObjectException: - self.logger.warning(f"{branch_name} does not yet exist") + self.logger.warning(f"{safe_github_ref(branch_name)} does not yet exist") return None - def create_repository_branch(self, branch_name: str, parent_branch: str | None = None): + def create_repository_branch(self, branch_name: str, parent_branch: str | None = None) -> Any | None: """Create a new repository branch.""" if self.repo is None: - self.logger.warning(f"Repository not set for {self.GITHUB_OWNER}, cannot create branch {branch_name}") + self.logger.warning( + f"Repository not set for {safe_github_ref(self.GITHUB_OWNER)}, " + f"cannot create branch {safe_github_ref(branch_name)}" + ) return None parent_branch_ref = self.get_repository_branch(parent_branch or self.repo.default_branch) - if is_nothing(parent_branch_ref): - raise RuntimeError( - f"Cannot create Git branch {branch_name}, parent branch {parent_branch} does not yet exist" + if parent_branch_ref is None or is_nothing(parent_branch_ref): + msg = ( + f"Cannot create Git branch {safe_github_ref(branch_name)}, " + f"parent branch {safe_github_ref(parent_branch)} does not yet exist" ) + raise RuntimeError(msg) try: return self.repo.create_git_ref( @@ -105,10 +160,11 @@ def create_repository_branch(self, branch_name: str, parent_branch: str | None = ) except GithubException as exc: if get_github_api_error(exc) == "Reference already exists": - self.logger.info(f"Branch {branch_name} already exists in Git repository") + self.logger.info(f"Branch {safe_github_ref(branch_name)} already exists in Git repository") return self.get_repository_branch(branch_name) - raise RuntimeError(f"Failed to create branch {branch_name}") from exc + msg = f"Failed to create branch {safe_github_ref(branch_name)}: {safe_github_text(exc, branch_name)}" + raise RuntimeError(msg) from None def get_repository_file( self, @@ -119,19 +175,23 @@ def get_repository_file( charset: str | None = "utf-8", errors: str | None = "strict", raise_on_not_found: bool = False, - ): + ) -> ExtendedDict | ExtendedList[Any] | ExtendedString | ExtendedTuple[Any] | None: """Get a file from the repository.""" + file_path_text = os.fspath(file_path) + safe_file_path = safe_github_ref(file_path_text) if self.repo is None: - self.logger.warning(f"Repository not set for {self.GITHUB_OWNER}, cannot get file {file_path}") + self.logger.warning( + f"Repository not set for {safe_github_ref(self.GITHUB_OWNER)}, cannot get file {safe_file_path}" + ) return None - def state_negative_result(result: str): + def state_negative_result(result: str) -> None: self.logger.warning(result) if raise_on_not_found: raise FileNotFoundError(result) - def get_retval(d: str | None, s: str | None, p: FilePath): - retval = [d] + def get_retval(d: Any, s: str | None, p: str) -> Any: + retval: list[Any] = [d] if return_sha: retval.append(s) if return_path: @@ -140,42 +200,40 @@ def get_retval(d: str | None, s: str | None, p: FilePath): return retval[0] return tuple(retval) - file_data = {} if decode else "" + file_data: Any = {} if decode else "" file_sha = None - self.logger.debug(f"Getting repository file: {file_path}") + self.logger.debug(f"Getting repository file: {safe_file_path}") try: - raw_file_data = self.repo.get_contents(str(file_path), ref=self.GITHUB_BRANCH) + raw_file_data = self.repo.get_contents(file_path_text, ref=self.GITHUB_BRANCH) file_sha = raw_file_data.sha if is_nothing(raw_file_data.content): - self.logger.warning(f"{file_path} is empty of content: {self.GITHUB_BRANCH}") + self.logger.warning( + f"{safe_file_path} is empty of content: {safe_github_ref(self.GITHUB_BRANCH)}" + ) else: file_data = raw_file_data.decoded_content.decode(charset, errors) except (UnknownObjectException, AttributeError): - state_negative_result(f"{file_path} does not exist") + state_negative_result(f"{safe_file_path} does not exist") except ValueError as exc: - self.logger.warning(f"Reading {file_path} not supported: {exc}") + self.logger.warning(f"Reading {safe_file_path} not supported: {safe_github_text(exc, file_path_text)}") decode = False if not decode or is_nothing(file_data): - return get_retval(file_data, file_sha, file_path) + return self.extend_result(get_retval(file_data, file_sha, file_path_text)) # Decode file content based on file type - encoding = get_encoding_for_file_path(file_path) + encoding = get_encoding_for_file_path(file_path_text) try: - if encoding == "json": - decoded_data = decode_json(file_data) - elif encoding == "yaml": - decoded_data = decode_yaml(file_data) - else: - # For raw or unknown types, return the string as-is - decoded_data = file_data + decoded_data = decode_file(file_data, file_path=file_path_text, as_extended=True) except Exception as exc: - self.logger.warning(f"Failed to decode {file_path} as {encoding}: {exc}") + self.logger.warning( + f"Failed to decode {safe_file_path} as {encoding}: {safe_github_text(exc, file_path_text)}" + ) decoded_data = file_data - return get_retval(decoded_data, file_sha, file_path) + return self.extend_result(get_retval(decoded_data, file_sha, file_path_text)) def update_repository_file( self, @@ -186,63 +244,71 @@ def update_repository_file( allow_encoding: bool | str | None = None, allow_empty: bool = False, **format_opts: Any, - ): + ) -> Any | None: """Update a file in the repository.""" + file_path_text = os.fspath(file_path) + safe_file_path = safe_github_ref(file_path_text) if self.repo is None: - self.logger.warning(f"Repository not set for {self.GITHUB_OWNER}, cannot update file {file_path}") + self.logger.warning( + f"Repository not set for {safe_github_ref(self.GITHUB_OWNER)}, cannot update file {safe_file_path}" + ) return None if is_nothing(file_data) and not allow_empty: - self.logger.warning(f"Empty file data for {file_path} not allowed") + self.logger.warning(f"Empty file data for {safe_file_path} not allowed") return None if msg: - self.logger.info(msg) + self.logger.info("Using caller-provided repository file message") if allow_encoding is None: - allow_encoding = get_encoding_for_file_path(file_path) + allow_encoding = get_encoding_for_file_path(file_path_text) file_data = wrap_raw_data_for_export(file_data, allow_encoding=allow_encoding, **format_opts) if not isinstance(file_data, str): file_data = str(file_data) - self.logger.info(f"Updating repository file: {file_path}") + self.logger.info(f"Updating repository file: {safe_file_path}") if file_sha is None: - result = self.get_repository_file(file_path, return_sha=True) + result = self.get_repository_file(file_path_text, return_sha=True) if isinstance(result, tuple): _, file_sha = result if file_sha is None: if msg is None: - msg = f"Creating {file_path}" + msg = f"Creating {file_path_text}" return self.repo.create_file( - path=str(file_path), + path=file_path_text, message=msg, branch=self.GITHUB_BRANCH, content=file_data, ) else: if msg is None: - msg = f"Updating {file_path}" + msg = f"Updating {file_path_text}" return self.repo.update_file( - path=str(file_path), + path=file_path_text, message=msg, content=file_data, sha=file_sha, branch=self.GITHUB_BRANCH, ) - def delete_repository_file(self, file_path: FilePath, msg: str | None = None): + def delete_repository_file(self, file_path: FilePath, msg: str | None = None) -> Any | None: """Delete a file from the repository.""" + file_path_text = os.fspath(file_path) + safe_file_path = safe_github_ref(file_path_text) if self.repo is None: - self.logger.warning(f"Repository not set for {self.GITHUB_OWNER}, cannot delete file {file_path}") + self.logger.warning( + f"Repository not set for {safe_github_ref(self.GITHUB_OWNER)}, cannot delete file {safe_file_path}" + ) return None - self.logger.info(f"Deleting repository file: {file_path}") + self.logger.info(f"Deleting repository file: {safe_file_path}") - result = self.get_repository_file(file_path=file_path, return_sha=True) + result = self.get_repository_file(file_path=file_path_text, return_sha=True) sha = None if isinstance(result, tuple): _, sha = result @@ -251,10 +317,10 @@ def delete_repository_file(self, file_path: FilePath, msg: str | None = None): return None if msg is None: - msg = f"Deleting {file_path}" + msg = f"Deleting {file_path_text}" return self.repo.delete_file( - path=str(file_path), + path=file_path_text, message=msg, branch=self.GITHUB_BRANCH, sha=sha, @@ -268,7 +334,7 @@ def list_org_members( self, role: str | None = None, include_pending: bool = False, - ) -> dict[str, dict[str, Any]]: + ) -> ExtendedDict: """List organization members. Args: @@ -278,7 +344,7 @@ def list_org_members( Returns: Dictionary mapping usernames to member data. """ - self.logger.info(f"Listing members for organization: {self.GITHUB_OWNER}") + self.logger.info(f"Listing members for organization: {safe_github_ref(self.GITHUB_OWNER)}") members: dict[str, dict[str, Any]] = {} @@ -314,9 +380,9 @@ def list_org_members( } self.logger.info(f"Retrieved {len(members)} organization members") - return members + return self.extend_result(members) - def get_org_member(self, username: str) -> dict[str, Any] | None: + def get_org_member(self, username: str) -> ExtendedDict | None: """Get a specific organization member. Args: @@ -328,18 +394,20 @@ def get_org_member(self, username: str) -> dict[str, Any] | None: try: member = self.git.get_user(username) membership = self.org.get_user_membership(member) - return { - "id": member.id, - "login": member.login, - "name": member.name, - "email": member.email, - "role": membership.role, - "state": membership.state, - "avatar_url": member.avatar_url, - "html_url": member.html_url, - } + return self.extend_result( + { + "id": member.id, + "login": member.login, + "name": member.name, + "email": member.email, + "role": membership.role, + "state": membership.state, + "avatar_url": member.avatar_url, + "html_url": member.html_url, + } + ) except UnknownObjectException: - self.logger.warning(f"User not found: {username}") + self.logger.warning(f"User not found: {safe_github_ref(username)}") return None # ========================================================================= @@ -350,7 +418,7 @@ def list_repositories( self, type_filter: str = "all", include_branches: bool = False, - ) -> dict[str, dict[str, Any]]: + ) -> ExtendedDict: """List organization repositories. Args: @@ -360,7 +428,7 @@ def list_repositories( Returns: Dictionary mapping repo names to repository data. """ - self.logger.info(f"Listing repositories for organization: {self.GITHUB_OWNER}") + self.logger.info(f"Listing repositories for organization: {safe_github_ref(self.GITHUB_OWNER)}") repos: dict[str, dict[str, Any]] = {} @@ -398,9 +466,9 @@ def list_repositories( repos[repo.name] = repo_data self.logger.info(f"Retrieved {len(repos)} repositories") - return repos + return self.extend_result(repos) - def get_repository(self, repo_name: str) -> dict[str, Any] | None: + def get_repository(self, repo_name: str) -> ExtendedDict | None: """Get a specific repository. Args: @@ -411,22 +479,24 @@ def get_repository(self, repo_name: str) -> dict[str, Any] | None: """ try: repo = self.git.get_repo(f"{self.GITHUB_OWNER}/{repo_name}") - return { - "id": repo.id, - "name": repo.name, - "full_name": repo.full_name, - "description": repo.description, - "private": repo.private, - "archived": repo.archived, - "default_branch": repo.default_branch, - "html_url": repo.html_url, - "clone_url": repo.clone_url, - "ssh_url": repo.ssh_url, - "language": repo.language, - "topics": repo.topics, - } + return self.extend_result( + { + "id": repo.id, + "name": repo.name, + "full_name": repo.full_name, + "description": repo.description, + "private": repo.private, + "archived": repo.archived, + "default_branch": repo.default_branch, + "html_url": repo.html_url, + "clone_url": repo.clone_url, + "ssh_url": repo.ssh_url, + "language": repo.language, + "topics": repo.topics, + } + ) except UnknownObjectException: - self.logger.warning(f"Repository not found: {repo_name}") + self.logger.warning(f"Repository not found: {safe_github_ref(repo_name)}") return None # ========================================================================= @@ -437,7 +507,7 @@ def list_teams( self, include_members: bool = False, include_repos: bool = False, - ) -> dict[str, dict[str, Any]]: + ) -> ExtendedDict: """List organization teams. Args: @@ -447,7 +517,7 @@ def list_teams( Returns: Dictionary mapping team slugs to team data. """ - self.logger.info(f"Listing teams for organization: {self.GITHUB_OWNER}") + self.logger.info(f"Listing teams for organization: {safe_github_ref(self.GITHUB_OWNER)}") teams: dict[str, dict[str, Any]] = {} @@ -491,9 +561,9 @@ def list_teams( teams[team.slug] = team_data self.logger.info(f"Retrieved {len(teams)} teams") - return teams + return self.extend_result(teams) - def get_team(self, team_slug: str) -> dict[str, Any] | None: + def get_team(self, team_slug: str) -> ExtendedDict | None: """Get a specific team. Args: @@ -504,19 +574,21 @@ def get_team(self, team_slug: str) -> dict[str, Any] | None: """ try: team = self.org.get_team_by_slug(team_slug) - return { - "id": team.id, - "name": team.name, - "slug": team.slug, - "description": team.description, - "privacy": team.privacy, - "permission": team.permission, - "html_url": team.html_url, - "members_count": team.members_count, - "repos_count": team.repos_count, - } + return self.extend_result( + { + "id": team.id, + "name": team.name, + "slug": team.slug, + "description": team.description, + "privacy": team.privacy, + "permission": team.permission, + "html_url": team.html_url, + "members_count": team.members_count, + "repos_count": team.repos_count, + } + ) except UnknownObjectException: - self.logger.warning(f"Team not found: {team_slug}") + self.logger.warning(f"Team not found: {safe_github_ref(team_slug)}") return None def add_team_member(self, team_slug: str, username: str, role: str = "member") -> bool: @@ -530,15 +602,19 @@ def add_team_member(self, team_slug: str, username: str, role: str = "member") - Returns: True if successful. """ - self.logger.info(f"Adding {username} to team {team_slug}") + safe_username = safe_github_ref(username) + safe_team = safe_github_ref(team_slug) + self.logger.info(f"Adding {safe_username} to team {safe_team}") try: team = self.org.get_team_by_slug(team_slug) user = self.git.get_user(username) team.add_membership(user, role=role) - self.logger.info(f"Added {username} to team {team_slug}") + self.logger.info(f"Added {safe_username} to team {safe_team}") return True except (UnknownObjectException, GithubException) as e: - self.logger.exception(f"Failed to add {username} to team: {e}") + self.logger.error( # noqa: TRY400 - traceback can expose raw GitHub identifiers. + f"Failed to add {safe_username} to team {safe_team}: {safe_github_text(e, username, team_slug)}" + ) return False def remove_team_member(self, team_slug: str, username: str) -> bool: @@ -551,22 +627,26 @@ def remove_team_member(self, team_slug: str, username: str) -> bool: Returns: True if successful. """ - self.logger.info(f"Removing {username} from team {team_slug}") + safe_username = safe_github_ref(username) + safe_team = safe_github_ref(team_slug) + self.logger.info(f"Removing {safe_username} from team {safe_team}") try: team = self.org.get_team_by_slug(team_slug) user = self.git.get_user(username) team.remove_membership(user) - self.logger.info(f"Removed {username} from team {team_slug}") + self.logger.info(f"Removed {safe_username} from team {safe_team}") return True except (UnknownObjectException, GithubException) as e: - self.logger.exception(f"Failed to remove {username} from team: {e}") + self.logger.error( # noqa: TRY400 - traceback can expose raw GitHub identifiers. + f"Failed to remove {safe_username} from team {safe_team}: {safe_github_text(e, username, team_slug)}" + ) return False # ========================================================================= # GraphQL Queries # ========================================================================= - def execute_graphql(self, query: str, variables: dict[str, Any] | None = None) -> dict[str, Any]: + def execute_graphql(self, query: str, variables: dict[str, Any] | None = None) -> ExtendedDict: """Execute a GraphQL query against the GitHub API. Args: @@ -577,10 +657,12 @@ def execute_graphql(self, query: str, variables: dict[str, Any] | None = None) - Query response data. """ headers = {"Authorization": f"Bearer {self.GITHUB_TOKEN}"} - return self.graphql_client.execute( - query=query, - variables=variables or {}, - headers=headers, + return self.extend_result( + self.graphql_client.execute( + query=query, + variables=variables or {}, + headers=headers, + ) ) # ========================================================================= @@ -589,9 +671,9 @@ def execute_graphql(self, query: str, variables: dict[str, Any] | None = None) - def get_users_with_verified_emails( self, - members: dict[str, dict[str, Any]] | None = None, + members: Mapping[str, Mapping[str, Any]] | None = None, domain_filter: str | None = None, - ) -> dict[str, dict[str, Any]]: + ) -> ExtendedDict: """Get organization members with their verified emails. Uses GraphQL to get verified email addresses for org members. @@ -603,7 +685,7 @@ def get_users_with_verified_emails( Returns: Dictionary mapping usernames to member data with verified emails. """ - self.logger.info(f"Getting users with verified emails for {self.GITHUB_OWNER}") + self.logger.info(f"Getting users with verified emails for {safe_github_ref(self.GITHUB_OWNER)}") if members is None: members = self.list_org_members() @@ -629,7 +711,7 @@ def get_users_with_verified_emails( verified_emails = user_data.get("organizationVerifiedDomainEmails", []) primary_email = user_data.get("email") - enriched_data = member_data.copy() + enriched_data = dict(member_data) enriched_data["verified_emails"] = verified_emails enriched_data["primary_email"] = primary_email @@ -643,11 +725,14 @@ def get_users_with_verified_emails( enriched[username] = enriched_data except Exception as e: - self.logger.warning(f"Failed to get verified emails for {username}: {e}") - enriched[username] = member_data + self.logger.warning( + f"Failed to get verified emails for {safe_github_ref(username)}: " + f"{safe_github_text(e, username)}" + ) + enriched[username] = dict(member_data) self.logger.info(f"Retrieved verified emails for {len(enriched)} users") - return enriched + return self.extend_result(enriched) # ========================================================================= # GitHub Actions Workflows @@ -656,13 +741,13 @@ def get_users_with_verified_emails( def build_workflow( self, name: str, - on: dict[str, Any], - jobs: dict[str, dict[str, Any]], - env: dict[str, str] | None = None, - permissions: dict[str, str] | None = None, - concurrency: dict[str, Any] | None = None, - defaults: dict[str, Any] | None = None, - ) -> dict[str, Any]: + on: Mapping[str, Any], + jobs: Mapping[str, Mapping[str, Any]], + env: Mapping[str, str] | None = None, + permissions: Mapping[str, str] | None = None, + concurrency: Mapping[str, Any] | None = None, + defaults: Mapping[str, Any] | None = None, + ) -> ExtendedDict: """Build a GitHub Actions workflow structure. Args: @@ -695,20 +780,20 @@ def build_workflow( workflow["jobs"] = jobs - return workflow + return self.extend_result(workflow) def build_workflow_job( self, runs_on: str = "ubuntu-latest", - steps: list[dict[str, Any]] | None = None, - needs: list[str] | None = None, + steps: Sequence[Mapping[str, Any]] | None = None, + needs: Sequence[str] | None = None, if_condition: str | None = None, - env: dict[str, str] | None = None, - strategy: dict[str, Any] | None = None, + env: Mapping[str, str] | None = None, + strategy: Mapping[str, Any] | None = None, timeout_minutes: int | None = None, - services: dict[str, Any] | None = None, - outputs: dict[str, str] | None = None, - ) -> dict[str, Any]: + services: Mapping[str, Any] | None = None, + outputs: Mapping[str, str] | None = None, + ) -> ExtendedDict: """Build a GitHub Actions workflow job. Args: @@ -748,22 +833,22 @@ def build_workflow_job( if outputs: job["outputs"] = outputs - job["steps"] = steps or [] + job["steps"] = list(steps or []) - return job + return self.extend_result(job) def build_workflow_step( self, name: str, uses: str | None = None, run: str | None = None, - with_params: dict[str, Any] | None = None, - env: dict[str, str] | None = None, + with_params: Mapping[str, Any] | None = None, + env: Mapping[str, str] | None = None, if_condition: str | None = None, working_directory: str | None = None, shell: str | None = None, id: str | None = None, # noqa: A002 - ) -> dict[str, Any]: + ) -> ExtendedDict: """Build a GitHub Actions workflow step. Args: @@ -802,7 +887,7 @@ def build_workflow_step( if env: step["env"] = env - return step + return self.extend_result(step) def create_python_ci_workflow( self, @@ -812,7 +897,7 @@ def create_python_ci_workflow( format_command: str | None = "ruff format --check", install_command: str = "uv sync --all-packages", working_directory: str = ".", - ) -> dict[str, Any]: + ) -> ExtendedDict: """Create a standard Python CI workflow. Args: @@ -965,11 +1050,7 @@ def build_github_actions_workflow( if concurrency_group: workflow["concurrency"] = concurrency_group - yaml = YAML() - yaml.indent(mapping=2, sequence=4, offset=2) - buffer = io.StringIO() - yaml.dump(workflow, buffer) - return buffer.getvalue().strip() + return wrap_raw_data_for_export(workflow, allow_encoding="yaml").strip() from extended_data.connectors.github.tools import ( diff --git a/src/extended_data/connectors/github/_diagnostics.py b/src/extended_data/connectors/github/_diagnostics.py new file mode 100644 index 0000000..8a81bea --- /dev/null +++ b/src/extended_data/connectors/github/_diagnostics.py @@ -0,0 +1,33 @@ +"""GitHub connector diagnostic redaction helpers.""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from typing import Any + +from extended_data.primitives.redaction import redact_sensitive_text + + +def _iter_diagnostic_values(values: Iterable[Any]) -> Iterable[Any]: + """Yield scalar values from nested diagnostic context.""" + for value in values: + if value is None: + continue + if isinstance(value, Mapping): + yield from _iter_diagnostic_values(value.values()) + elif isinstance(value, (str, bytes)): + yield value + elif isinstance(value, Iterable): + yield from _iter_diagnostic_values(value) + else: + yield value + + +def safe_github_text(value: Any, *sensitive_values: Any) -> str: + """Redact secrets and caller-provided GitHub identifiers from diagnostics.""" + return redact_sensitive_text(value, values=_iter_diagnostic_values(sensitive_values)) + + +def safe_github_ref(value: Any) -> str: + """Redact a single GitHub resource reference for diagnostic logs.""" + return safe_github_text(value, value) diff --git a/src/extended_data/connectors/github/tools.py b/src/extended_data/connectors/github/tools.py index 527c720..f4efd19 100644 --- a/src/extended_data/connectors/github/tools.py +++ b/src/extended_data/connectors/github/tools.py @@ -10,6 +10,9 @@ from pydantic import BaseModel, Field +from extended_data.connectors.ai_tools import raise_unknown_tool_framework +from extended_data.containers import ExtendedDict, ExtendedList, extend_data + # ============================================================================= # Input Schemas @@ -79,8 +82,8 @@ def list_repositories( type_filter: str = "all", include_branches: bool = False, github_token: str | None = None, - **kwargs, -) -> list[dict[str, Any]]: + **kwargs: Any, +) -> ExtendedList[ExtendedDict]: """List repositories in a GitHub organization. Args: @@ -102,15 +105,15 @@ def list_repositories( repo_data = data.copy() repo_data["name"] = name result.append(repo_data) - return result + return extend_data(result) def get_repository( github_owner: str, repo_name: str, github_token: str | None = None, - **kwargs, -) -> dict[str, Any]: + **kwargs: Any, +) -> ExtendedDict: """Get details of a specific GitHub repository. Args: @@ -127,9 +130,8 @@ def get_repository( data = connector.get_repository(repo_name) if data: - return {"status": "found", **data} - else: - return {"status": "not_found", "name": repo_name} + return extend_data({"status": "found", **data}) + return extend_data({"status": "not_found", "name": repo_name}) def list_teams( @@ -137,8 +139,8 @@ def list_teams( include_members: bool = False, include_repos: bool = False, github_token: str | None = None, - **kwargs, -) -> list[dict[str, Any]]: + **kwargs: Any, +) -> ExtendedList[ExtendedDict]: """List teams in a GitHub organization. Args: @@ -154,15 +156,15 @@ def list_teams( connector = GitHubConnector(github_owner=github_owner, github_token=github_token) teams = connector.list_teams(include_members=include_members, include_repos=include_repos) - return list(teams.values()) + return extend_data(list(teams.values())) def get_team( github_owner: str, team_slug: str, github_token: str | None = None, - **kwargs, -) -> dict[str, Any]: + **kwargs: Any, +) -> ExtendedDict: """Get details of a specific GitHub team. Args: @@ -179,9 +181,8 @@ def get_team( data = connector.get_team(team_slug) if data: - return {"status": "found", **data} - else: - return {"status": "not_found", "slug": team_slug} + return extend_data({"status": "found", **data}) + return extend_data({"status": "not_found", "slug": team_slug}) def list_org_members( @@ -189,8 +190,8 @@ def list_org_members( role: str = "member", include_pending: bool = False, github_token: str | None = None, - **kwargs, -) -> list[dict[str, Any]]: + **kwargs: Any, +) -> ExtendedList[ExtendedDict]: """List members of a GitHub organization. Args: @@ -206,7 +207,7 @@ def list_org_members( connector = GitHubConnector(github_owner=github_owner, github_token=github_token) members = connector.list_org_members(role=role, include_pending=include_pending) - return list(members.values()) + return extend_data(list(members.values())) def get_repository_file( @@ -215,8 +216,8 @@ def get_repository_file( file_path: str, github_branch: str | None = None, github_token: str | None = None, - **kwargs, -) -> dict[str, Any]: + **kwargs: Any, +) -> ExtendedDict: """Get a file from a GitHub repository. Args: @@ -247,12 +248,14 @@ def get_repository_file( status = "empty" if content is None else "retrieved" - return { - "status": status, - "path": file_path, - "content": content, - "sha": sha, - } + return extend_data( + { + "status": status, + "path": file_path, + "content": content, + "sha": sha, + } + ) # ============================================================================= @@ -306,30 +309,16 @@ def get_repository_file( def get_langchain_tools() -> list[Any]: """Get all GitHub tools as LangChain StructuredTools.""" - try: - from langchain_core.tools import StructuredTool - except ImportError as e: - msg = "langchain-core is required for LangChain tools." - raise ImportError(msg) from e - - return [ - StructuredTool.from_function( - func=defn["func"], - name=defn["name"], - description=defn["description"], - args_schema=defn.get("schema") or defn.get("args_schema"), - ) - for defn in TOOL_DEFINITIONS - ] + from extended_data.connectors.ai_tools import build_langchain_tools + + return build_langchain_tools(TOOL_DEFINITIONS) def get_crewai_tools() -> list[Any]: """Get all GitHub tools as CrewAI tools.""" - try: - from crewai.tools import tool as crewai_tool - except ImportError as e: - msg = "crewai is required for CrewAI tools." - raise ImportError(msg) from e + from extended_data.connectors._optional import get_crewai_tool_decorator + + crewai_tool = get_crewai_tool_decorator() tools = [] for defn in TOOL_DEFINITIONS: @@ -363,10 +352,10 @@ def get_tools(framework: str = "auto") -> list[Any]: return get_langchain_tools() if framework == "crewai": return get_crewai_tools() - if framework in ("strands", "functions"): + if framework == "strands": return get_strands_tools() - raise ValueError(f"Unknown framework: {framework}") + return raise_unknown_tool_framework(framework) # ============================================================================= diff --git a/src/extended_data/connectors/google/__init__.py b/src/extended_data/connectors/google/__init__.py index 3b4cf02..abb957b 100644 --- a/src/extended_data/connectors/google/__init__.py +++ b/src/extended_data/connectors/google/__init__.py @@ -2,16 +2,42 @@ from __future__ import annotations -import json - from collections.abc import Sequence -from typing import Any - -from google.oauth2 import service_account -from googleapiclient.discovery import build +from typing import TYPE_CHECKING, Any, cast -from extended_data.connectors.base import VendorConnectorBase +from extended_data.connectors._optional import require_extra +from extended_data.connectors.base import ConnectorBase +from extended_data.connectors.google.billing import GoogleBillingMixin +from extended_data.connectors.google.cloud import GoogleCloudMixin +from extended_data.connectors.google.services import GoogleServicesMixin +from extended_data.connectors.google.workspace import GoogleWorkspaceMixin +from extended_data.containers import ExtendedDict, ExtendedList +from extended_data.io.files import decode_file from extended_data.logging import Logging +from extended_data.primitives.formats.errors import DataDecodeError +from extended_data.primitives.redaction import redact_sensitive_text + + +if TYPE_CHECKING: + from google.oauth2 import service_account + from googleapiclient.discovery import build +else: + service_account = None + build = None + + +def _load_google_sdk() -> None: + """Load Google SDK dependencies lazily so tool metadata remains importable.""" + global build, service_account + + if service_account is None or build is None: + try: + service_account = require_extra("google.oauth2.service_account", "google") + discovery = require_extra("googleapiclient.discovery", "google") + except ImportError as exc: + msg = "google-api-python-client is required for GoogleConnector. Install with: pip install extended-data[google]" + raise ImportError(msg) from exc + build = discovery.build # Default Google scopes @@ -26,15 +52,20 @@ ] -class GoogleConnector(VendorConnectorBase): - """Google Cloud and Workspace base connector. +class GoogleConnector( + GoogleWorkspaceMixin, + GoogleCloudMixin, + GoogleBillingMixin, + GoogleServicesMixin, + ConnectorBase, +): + """Google Cloud and Workspace connector. - This is the base connector class providing: + This first-class connector provides: - Authentication via service account - Service client creation and caching - Subject impersonation for domain-wide delegation - - Higher-level operations are provided via mixin classes from submodules. + - Workspace, Cloud Resource Manager, Billing, and service discovery operations """ def __init__( @@ -43,8 +74,8 @@ def __init__( scopes: list[str] | None = None, subject: str | None = None, logger: Logging | None = None, - **kwargs, - ): + **kwargs: Any, + ) -> None: """Initialize the Google connector. Args: @@ -53,9 +84,10 @@ def __init__( scopes: OAuth scopes to request. Defaults to common scopes. subject: Email to impersonate via domain-wide delegation. logger: Optional Logging instance. - **kwargs: Additional arguments passed to VendorConnectorBase. + **kwargs: Additional arguments passed to ConnectorBase. """ super().__init__(logger=logger, **kwargs) + _load_google_sdk() self.scopes = scopes or DEFAULT_SCOPES self.subject = subject @@ -67,12 +99,21 @@ def __init__( # Parse if string if isinstance(service_account_info, str): try: - service_account_info = json.loads(service_account_info) - except json.JSONDecodeError as e: - self.logger.exception(f"Failed to parse GOOGLE_SERVICE_ACCOUNT JSON: {e}") - raise - - self.service_account_info = service_account_info + service_account_info = decode_file(service_account_info, suffix="json", as_extended=False) + except DataDecodeError as e: + safe_payload = redact_sensitive_text(service_account_info, values=[service_account_info]) + error_message = ( + "Failed to parse GOOGLE_SERVICE_ACCOUNT JSON: " + f"{redact_sensitive_text(e, values=[service_account_info])}. Payload: {safe_payload}" + ) + self.logger.error(error_message) # noqa: TRY400 - traceback can expose raw service-account payloads. + raise ValueError(error_message) from None + + if not isinstance(service_account_info, dict): + msg = "Google service account info must be a JSON object" + raise TypeError(msg) + + self.service_account_info: dict[str, Any] = service_account_info self._credentials: service_account.Credentials | None = None self._services: dict[str, Any] = {} @@ -90,7 +131,8 @@ def credentials(self) -> service_account.Credentials: Authenticated service account credentials. """ if self._credentials is None: - self._credentials = service_account.Credentials.from_service_account_info( + credentials_class = cast(Any, service_account.Credentials) + self._credentials = credentials_class.from_service_account_info( self.service_account_info, scopes=self.scopes, ) @@ -108,7 +150,8 @@ def get_credentials_for_subject(self, subject: str) -> service_account.Credentia Returns: Credentials with the specified subject. """ - return service_account.Credentials.from_service_account_info( + credentials_class = cast(Any, service_account.Credentials) + return credentials_class.from_service_account_info( self.service_account_info, scopes=self.scopes, ).with_subject(subject) @@ -238,8 +281,8 @@ def _resolve_sequence_option( candidate = raw_value.strip() if candidate: try: - parsed = json.loads(candidate) - except json.JSONDecodeError: + parsed = decode_file(candidate, suffix="json", as_extended=False) + except DataDecodeError: pass else: return self._normalize_str_sequence(parsed) @@ -253,8 +296,8 @@ def _normalize_str_sequence(value: Sequence[Any] | str | None) -> list[str] | No return None if isinstance(value, str): - normalized = [item.strip() for item in value.split(",") if item.strip()] - return normalized or None + parts = [item.strip() for item in value.split(",") if item.strip()] + return parts or None normalized: list[str] = [] for item in value: @@ -389,7 +432,7 @@ def list_users( exclude_bots: bool | None = None, flatten_names: bool | None = None, key_by_email: bool | None = None, - ) -> list[dict[str, Any]] | dict[str, dict[str, Any]]: + ) -> ExtendedList[ExtendedDict] | ExtendedDict: """List users from Google Workspace with optional filtering. Args: @@ -445,9 +488,11 @@ def list_users( ) if return_keyed: - return self._key_results_by_email(filtered_users, primary_field="primaryEmail", fallback_field="email") + return self.extend_result( + self._key_results_by_email(filtered_users, primary_field="primaryEmail", fallback_field="email") + ) - return filtered_users + return self.extend_result(filtered_users) def list_groups( self, @@ -460,7 +505,7 @@ def list_groups( exclude_bots: bool | None = None, flatten_names: bool | None = None, key_by_email: bool | None = None, - ) -> list[dict[str, Any]] | dict[str, dict[str, Any]]: + ) -> ExtendedList[ExtendedDict] | ExtendedDict: """List groups from Google Workspace with optional filtering. Args: @@ -516,14 +561,13 @@ def list_groups( ) if return_keyed: - return self._key_results_by_email(filtered_groups, primary_field="email", fallback_field="primaryEmail") + return self.extend_result( + self._key_results_by_email(filtered_groups, primary_field="email", fallback_field="primaryEmail") + ) - return filtered_groups + return self.extend_result(filtered_groups) -# Import submodule operations -from extended_data.connectors.google.billing import GoogleBillingMixin -from extended_data.connectors.google.cloud import GoogleCloudMixin from extended_data.connectors.google.constants import ( DEFAULT_DOMAIN, DEFAULT_USER_OUS, @@ -540,37 +584,12 @@ def list_groups( SessionState, Source, ) -from extended_data.connectors.google.services import GoogleServicesMixin from extended_data.connectors.google.tools import ( get_crewai_tools, get_langchain_tools, get_strands_tools, get_tools, ) -from extended_data.connectors.google.workspace import GoogleWorkspaceMixin - - -class GoogleConnectorFull( - GoogleConnector, GoogleWorkspaceMixin, GoogleCloudMixin, GoogleBillingMixin, GoogleServicesMixin -): - """Full Google connector with all operations. - - This class combines the base GoogleConnector with all operation mixins. - Use this for full functionality, or use GoogleConnector directly and - import specific mixins as needed. - """ - - -class GoogleCloudConnector(GoogleConnector, GoogleCloudMixin): - """Google connector focused on Cloud Resource Manager and IAM operations.""" - - -class GoogleWorkspaceConnector(GoogleConnector, GoogleWorkspaceMixin): - """Google connector focused on Admin Directory user and group operations.""" - - -class GoogleBillingConnector(GoogleConnector, GoogleBillingMixin): - """Google connector focused on Cloud Billing account and project billing operations.""" __all__ = [ @@ -582,14 +601,10 @@ class GoogleBillingConnector(GoogleConnector, GoogleBillingMixin): "GCP_REQUIRED_ORGANIZATION_ROLES", "GCP_REQUIRED_ROLES", "GCP_SECURITY_PROJECT", - "GoogleBillingConnector", "GoogleBillingMixin", - "GoogleCloudConnector", "GoogleCloudMixin", "GoogleConnector", - "GoogleConnectorFull", "GoogleServicesMixin", - "GoogleWorkspaceConnector", "GoogleWorkspaceMixin", "JulesConnector", "JulesError", diff --git a/src/extended_data/connectors/google/_diagnostics.py b/src/extended_data/connectors/google/_diagnostics.py new file mode 100644 index 0000000..7848443 --- /dev/null +++ b/src/extended_data/connectors/google/_diagnostics.py @@ -0,0 +1,33 @@ +"""Google connector diagnostic redaction helpers.""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from typing import Any + +from extended_data.primitives.redaction import redact_sensitive_text + + +def _iter_diagnostic_values(values: Iterable[Any]) -> Iterable[Any]: + """Yield scalar values from nested diagnostic context.""" + for value in values: + if value is None: + continue + if isinstance(value, Mapping): + yield from _iter_diagnostic_values(value.values()) + elif isinstance(value, (str, bytes)): + yield value + elif isinstance(value, Iterable): + yield from _iter_diagnostic_values(value) + else: + yield value + + +def safe_google_text(value: Any, *sensitive_values: Any) -> str: + """Redact secrets and caller-provided resource identifiers from diagnostics.""" + return redact_sensitive_text(value, values=_iter_diagnostic_values(sensitive_values)) + + +def safe_google_ref(value: Any) -> str: + """Redact a single Google resource reference for diagnostic logs.""" + return safe_google_text(value, value) diff --git a/src/extended_data/connectors/google/billing.py b/src/extended_data/connectors/google/billing.py index 3425960..0ecb5e2 100644 --- a/src/extended_data/connectors/google/billing.py +++ b/src/extended_data/connectors/google/billing.py @@ -6,9 +6,11 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any, cast -from extended_data import unhump_map +from extended_data.connectors.google._diagnostics import safe_google_ref, safe_google_text +from extended_data.containers import ExtendedDict, ExtendedList, to_builtin +from extended_data.primitives import unhump_map class GoogleBillingMixin: @@ -19,11 +21,19 @@ class GoogleBillingMixin: - logger """ + if TYPE_CHECKING: + logger: Any + service_account_info: dict[str, Any] + + def get_billing_service(self) -> Any: ... + + def extend_result(self, value: Any) -> Any: ... + def list_billing_accounts( self, filter_query: str | None = None, unhump_accounts: bool = False, - ) -> list[dict[str, Any]]: + ) -> ExtendedList[ExtendedDict]: """List Google Cloud billing accounts. Args: @@ -58,9 +68,9 @@ def list_billing_accounts( if unhump_accounts: accounts = [unhump_map(a) for a in accounts] - return accounts + return self.extend_result(accounts) - def get_billing_account(self, billing_account_id: str) -> dict[str, Any] | None: + def get_billing_account(self, billing_account_id: str) -> ExtendedDict | None: """Get a specific billing account. Args: @@ -77,14 +87,14 @@ def get_billing_account(self, billing_account_id: str) -> dict[str, Any] | None: name = f"billingAccounts/{billing_account_id}" try: - return service.billingAccounts().get(name=name).execute() + return self.extend_result(service.billingAccounts().get(name=name).execute()) except HttpError as e: if e.resp.status == 404: - self.logger.warning(f"Billing account not found: {billing_account_id}") + self.logger.warning(f"Billing account not found: {safe_google_ref(billing_account_id)}") return None raise - def get_project_billing_info(self, project_id: str) -> dict[str, Any] | None: + def get_project_billing_info(self, project_id: str) -> ExtendedDict | None: """Get billing info for a project. Args: @@ -98,10 +108,10 @@ def get_project_billing_info(self, project_id: str) -> dict[str, Any] | None: service = self.get_billing_service() try: - return service.projects().getBillingInfo(name=f"projects/{project_id}").execute() + return self.extend_result(service.projects().getBillingInfo(name=f"projects/{project_id}").execute()) except HttpError as e: if e.resp.status == 404: - self.logger.warning(f"Project billing info not found: {project_id}") + self.logger.warning(f"Project billing info not found: {safe_google_ref(project_id)}") return None raise @@ -109,7 +119,7 @@ def update_project_billing_info( self, project_id: str, billing_account_name: str, - ) -> dict[str, Any]: + ) -> ExtendedDict: """Link a project to a billing account. Args: @@ -119,7 +129,9 @@ def update_project_billing_info( Returns: Updated billing info dictionary. """ - self.logger.info(f"Linking project {project_id} to {billing_account_name}") + safe_project = safe_google_ref(project_id) + safe_billing_account = safe_google_ref(billing_account_name) + self.logger.info(f"Linking project {safe_project} to {safe_billing_account}") service = self.get_billing_service() if not billing_account_name.startswith("billingAccounts/"): @@ -134,10 +146,10 @@ def update_project_billing_info( .execute() ) - self.logger.info(f"Linked project {project_id} to billing account") - return result + self.logger.info(f"Linked project {safe_project} to billing account") + return self.extend_result(result) - def disable_project_billing(self, project_id: str) -> dict[str, Any]: + def disable_project_billing(self, project_id: str) -> ExtendedDict: """Disable billing for a project. Args: @@ -146,7 +158,8 @@ def disable_project_billing(self, project_id: str) -> dict[str, Any]: Returns: Updated billing info dictionary. """ - self.logger.info(f"Disabling billing for project {project_id}") + safe_project = safe_google_ref(project_id) + self.logger.info(f"Disabling billing for project {safe_project}") service = self.get_billing_service() result = ( @@ -158,14 +171,14 @@ def disable_project_billing(self, project_id: str) -> dict[str, Any]: .execute() ) - self.logger.info(f"Disabled billing for project {project_id}") - return result + self.logger.info(f"Disabled billing for project {safe_project}") + return self.extend_result(result) def list_billing_account_projects( self, billing_account_id: str, unhump_projects: bool = False, - ) -> list[dict[str, Any]]: + ) -> ExtendedList[ExtendedDict]: """List projects linked to a billing account. Args: @@ -175,7 +188,7 @@ def list_billing_account_projects( Returns: List of project billing info dictionaries. """ - self.logger.info(f"Listing projects for billing account {billing_account_id}") + self.logger.info(f"Listing projects for billing account {safe_google_ref(billing_account_id)}") service = self.get_billing_service() name = billing_account_id @@ -202,12 +215,12 @@ def list_billing_account_projects( if unhump_projects: projects = [unhump_map(p) for p in projects] - return projects + return self.extend_result(projects) def get_billing_account_iam_policy( self, billing_account_id: str, - ) -> dict[str, Any]: + ) -> ExtendedDict: """Get IAM policy for a billing account. Args: @@ -222,13 +235,13 @@ def get_billing_account_iam_policy( if not name.startswith("billingAccounts/"): name = f"billingAccounts/{billing_account_id}" - return service.billingAccounts().getIamPolicy(resource=name).execute() + return self.extend_result(service.billingAccounts().getIamPolicy(resource=name).execute()) def set_billing_account_iam_policy( self, billing_account_id: str, policy: dict[str, Any], - ) -> dict[str, Any]: + ) -> ExtendedDict: """Set IAM policy for a billing account. Args: @@ -238,18 +251,18 @@ def set_billing_account_iam_policy( Returns: Updated IAM policy dictionary. """ - self.logger.info(f"Setting IAM policy on billing account {billing_account_id}") + self.logger.info(f"Setting IAM policy on billing account {safe_google_ref(billing_account_id)}") service = self.get_billing_service() name = billing_account_id if not name.startswith("billingAccounts/"): name = f"billingAccounts/{billing_account_id}" - return ( + return self.extend_result( service.billingAccounts() .setIamPolicy( resource=name, - body={"policy": policy}, + body={"policy": to_builtin(policy)}, ) .execute() ) @@ -258,7 +271,7 @@ def get_bigquery_billing_dataset( self, project_id: str, dataset_id: str = "billing_export", - ) -> dict[str, Any] | None: + ) -> ExtendedDict | None: """Get BigQuery billing export dataset configuration. Args: @@ -270,13 +283,15 @@ def get_bigquery_billing_dataset( """ from googleapiclient.errors import HttpError - self.logger.info(f"Getting BigQuery billing dataset {project_id}.{dataset_id}") + safe_dataset_ref = safe_google_text(f"{project_id}.{dataset_id}", project_id, dataset_id) + self.logger.info(f"Getting BigQuery billing dataset {safe_dataset_ref}") # Build BigQuery client from google.oauth2 import service_account from googleapiclient.discovery import build - credentials = service_account.Credentials.from_service_account_info( + credentials_class = cast(Any, service_account.Credentials) + credentials = credentials_class.from_service_account_info( self.service_account_info, scopes=["https://www.googleapis.com/auth/bigquery.readonly"], ) @@ -294,17 +309,19 @@ def get_bigquery_billing_dataset( t for t in tables if "gcp_billing_export" in t.get("tableReference", {}).get("tableId", "") ] - return { - "dataset": dataset, - "tables": tables, - "billing_tables": billing_tables, - "location": dataset.get("location"), - "description": dataset.get("description"), - } + return self.extend_result( + { + "dataset": dataset, + "tables": tables, + "billing_tables": billing_tables, + "location": dataset.get("location"), + "description": dataset.get("description"), + } + ) except HttpError as e: if e.resp.status == 404: - self.logger.warning(f"Billing dataset not found: {project_id}.{dataset_id}") + self.logger.warning(f"Billing dataset not found: {safe_dataset_ref}") return None raise @@ -314,7 +331,7 @@ def setup_billing_export( project_id: str, dataset_id: str = "billing_export", location: str = "US", - ) -> dict[str, Any]: + ) -> ExtendedDict: """Set up BigQuery billing export for a billing account. Creates the dataset if it doesn't exist and returns configuration. @@ -330,13 +347,16 @@ def setup_billing_export( """ from googleapiclient.errors import HttpError - self.logger.info(f"Setting up billing export for {billing_account_id}") + safe_billing_account = safe_google_ref(billing_account_id) + safe_dataset = safe_google_ref(dataset_id) + self.logger.info(f"Setting up billing export for {safe_billing_account}") # Build BigQuery client from google.oauth2 import service_account from googleapiclient.discovery import build - credentials = service_account.Credentials.from_service_account_info( + credentials_class = cast(Any, service_account.Credentials) + credentials = credentials_class.from_service_account_info( self.service_account_info, scopes=["https://www.googleapis.com/auth/bigquery"], ) @@ -346,7 +366,7 @@ def setup_billing_export( # Check if dataset exists try: dataset = service.datasets().get(projectId=project_id, datasetId=dataset_id).execute() - self.logger.info(f"Dataset {dataset_id} already exists") + self.logger.info(f"Dataset {safe_dataset} already exists") except HttpError as e: if e.resp.status != 404: raise @@ -366,12 +386,14 @@ def setup_billing_export( } dataset = service.datasets().insert(projectId=project_id, body=dataset_body).execute() - self.logger.info(f"Created billing export dataset: {dataset_id}") - - return { - "billing_account_id": billing_account_id, - "project_id": project_id, - "dataset_id": dataset_id, - "location": dataset.get("location"), - "full_dataset_id": f"{project_id}.{dataset_id}", - } + self.logger.info(f"Created billing export dataset: {safe_dataset}") + + return self.extend_result( + { + "billing_account_id": billing_account_id, + "project_id": project_id, + "dataset_id": dataset_id, + "location": dataset.get("location"), + "full_dataset_id": f"{project_id}.{dataset_id}", + } + ) diff --git a/src/extended_data/connectors/google/cloud.py b/src/extended_data/connectors/google/cloud.py index 0ff8763..2a5a1a6 100644 --- a/src/extended_data/connectors/google/cloud.py +++ b/src/extended_data/connectors/google/cloud.py @@ -6,9 +6,12 @@ from __future__ import annotations -from typing import Any +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any -from extended_data import unhump_map +from extended_data.connectors.google._diagnostics import safe_google_ref, safe_google_text +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString, to_builtin +from extended_data.primitives import unhump_map class GoogleCloudMixin: @@ -20,7 +23,16 @@ class GoogleCloudMixin: - logger """ - def get_organization_id(self) -> str: + if TYPE_CHECKING: + logger: Any + + def get_cloud_resource_manager_service(self) -> Any: ... + + def get_iam_service(self) -> Any: ... + + def extend_result(self, value: Any) -> Any: ... + + def get_organization_id(self) -> ExtendedString: """Get the Google Cloud organization ID. Returns: @@ -41,10 +53,10 @@ def get_organization_id(self) -> str: org_name = organizations[0]["name"] org_id = org_name.split("/")[-1] - self.logger.info(f"Organization ID: {org_id}") - return org_id + self.logger.info(f"Organization ID: {safe_google_ref(org_id)}") + return self.extend_result(org_id) - def get_organization(self) -> dict[str, Any]: + def get_organization(self) -> ExtendedDict: """Get the Google Cloud organization details. Returns: @@ -63,14 +75,14 @@ def get_organization(self) -> dict[str, Any]: msg = "No organizations found" raise RuntimeError(msg) - return organizations[0] + return self.extend_result(organizations[0]) def list_projects( self, parent: str | None = None, filter_query: str | None = None, unhump_projects: bool = False, - ) -> list[dict[str, Any]]: + ) -> ExtendedList[ExtendedDict]: """List Google Cloud projects. Args: @@ -108,9 +120,9 @@ def list_projects( if unhump_projects: projects = [unhump_map(p) for p in projects] - return projects + return self.extend_result(projects) - def get_project(self, project_id: str) -> dict[str, Any] | None: + def get_project(self, project_id: str) -> ExtendedDict | None: """Get a specific Google Cloud project. Args: @@ -124,10 +136,10 @@ def get_project(self, project_id: str) -> dict[str, Any] | None: service = self.get_cloud_resource_manager_service() try: - return service.projects().get(name=f"projects/{project_id}").execute() + return self.extend_result(service.projects().get(name=f"projects/{project_id}").execute()) except HttpError as e: if e.resp.status == 404: - self.logger.warning(f"Project not found: {project_id}") + self.logger.warning(f"Project not found: {safe_google_ref(project_id)}") return None raise @@ -137,7 +149,7 @@ def create_project( display_name: str, parent: str | None = None, labels: dict[str, str] | None = None, - ) -> dict[str, Any]: + ) -> ExtendedDict: """Create a Google Cloud project. Args: @@ -149,7 +161,8 @@ def create_project( Returns: Operation response dictionary. """ - self.logger.info(f"Creating project: {project_id}") + safe_project = safe_google_ref(project_id) + self.logger.info(f"Creating project: {safe_project}") service = self.get_cloud_resource_manager_service() project_body: dict[str, Any] = { @@ -160,13 +173,13 @@ def create_project( if parent: project_body["parent"] = parent if labels: - project_body["labels"] = labels + project_body["labels"] = to_builtin(labels) result = service.projects().create(body=project_body).execute() - self.logger.info(f"Created project: {project_id}") - return result + self.logger.info(f"Created project: {safe_project}") + return self.extend_result(result) - def delete_project(self, project_id: str) -> dict[str, Any]: + def delete_project(self, project_id: str) -> ExtendedDict: """Delete a Google Cloud project. Args: @@ -175,18 +188,19 @@ def delete_project(self, project_id: str) -> dict[str, Any]: Returns: Operation response dictionary. """ - self.logger.info(f"Deleting project: {project_id}") + safe_project = safe_google_ref(project_id) + self.logger.info(f"Deleting project: {safe_project}") service = self.get_cloud_resource_manager_service() result = service.projects().delete(name=f"projects/{project_id}").execute() - self.logger.info(f"Deleted project: {project_id}") - return result + self.logger.info(f"Deleted project: {safe_project}") + return self.extend_result(result) def move_project( self, project_id: str, destination_parent: str, - ) -> dict[str, Any]: + ) -> ExtendedDict: """Move a project to a different folder/organization. Args: @@ -196,7 +210,9 @@ def move_project( Returns: Operation response dictionary. """ - self.logger.info(f"Moving project {project_id} to {destination_parent}") + safe_project = safe_google_ref(project_id) + safe_destination = safe_google_ref(destination_parent) + self.logger.info(f"Moving project {safe_project} to {safe_destination}") service = self.get_cloud_resource_manager_service() result = ( @@ -207,14 +223,14 @@ def move_project( ) .execute() ) - self.logger.info(f"Moved project {project_id}") - return result + self.logger.info(f"Moved project {safe_project}") + return self.extend_result(result) def list_folders( self, parent: str, unhump_folders: bool = False, - ) -> list[dict[str, Any]]: + ) -> ExtendedList[ExtendedDict]: """List folders under a parent. Args: @@ -224,7 +240,7 @@ def list_folders( Returns: List of folder dictionaries. """ - self.logger.info(f"Listing folders under {parent}") + self.logger.info(f"Listing folders under {safe_google_ref(parent)}") service = self.get_cloud_resource_manager_service() folders: list[dict[str, Any]] = [] @@ -247,13 +263,13 @@ def list_folders( if unhump_folders: folders = [unhump_map(f) for f in folders] - return folders + return self.extend_result(folders) def get_org_policy( self, resource: str, constraint: str, - ) -> dict[str, Any] | None: + ) -> ExtendedDict | None: """Get an organization policy. Args: @@ -268,7 +284,7 @@ def get_org_policy( service = self.get_cloud_resource_manager_service() try: - return ( + return self.extend_result( service.organizations() .getOrgPolicy( resource=resource, @@ -285,7 +301,7 @@ def set_org_policy( self, resource: str, policy: dict[str, Any], - ) -> dict[str, Any]: + ) -> ExtendedDict: """Set an organization policy. Args: @@ -295,14 +311,14 @@ def set_org_policy( Returns: Updated policy dictionary. """ - self.logger.info(f"Setting org policy on {resource}") + self.logger.info(f"Setting org policy on {safe_google_ref(resource)}") service = self.get_cloud_resource_manager_service() - return ( + return self.extend_result( service.organizations() .setOrgPolicy( resource=resource, - body={"policy": policy}, + body={"policy": to_builtin(policy)}, ) .execute() ) @@ -311,7 +327,7 @@ def get_iam_policy( self, resource: str, resource_type: str = "projects", - ) -> dict[str, Any]: + ) -> ExtendedDict: """Get IAM policy for a resource. Args: @@ -351,14 +367,14 @@ def get_iam_policy( .execute() ) - return result + return self.extend_result(result) def set_iam_policy( self, resource: str, - policy: dict[str, Any], + policy: Mapping[str, Any], resource_type: str = "projects", - ) -> dict[str, Any]: + ) -> ExtendedDict: """Set IAM policy for a resource. Args: @@ -369,10 +385,11 @@ def set_iam_policy( Returns: Updated IAM policy dictionary. """ - self.logger.info(f"Setting IAM policy on {resource_type}/{resource}") + safe_resource = safe_google_text(f"{resource_type}/{resource}", resource) + self.logger.info(f"Setting IAM policy on {safe_resource}") service = self.get_cloud_resource_manager_service() - body = {"policy": policy} + body = {"policy": to_builtin(policy)} if resource_type == "projects": result = ( @@ -402,8 +419,8 @@ def set_iam_policy( .execute() ) - self.logger.info(f"Set IAM policy on {resource_type}/{resource}") - return result + self.logger.info(f"Set IAM policy on {safe_resource}") + return self.extend_result(result) def add_iam_binding( self, @@ -411,7 +428,7 @@ def add_iam_binding( role: str, member: str, resource_type: str = "projects", - ) -> dict[str, Any]: + ) -> ExtendedDict: """Add an IAM binding to a resource. Args: @@ -423,7 +440,9 @@ def add_iam_binding( Returns: Updated IAM policy dictionary. """ - self.logger.info(f"Adding IAM binding: {role} -> {member} on {resource}") + self.logger.info( + f"Adding IAM binding: {role} -> {safe_google_ref(member)} on {safe_google_ref(resource)}" + ) policy = self.get_iam_policy(resource, resource_type) bindings = policy.get("bindings", []) @@ -448,7 +467,7 @@ def list_service_accounts( self, project_id: str, unhump_accounts: bool = False, - ) -> list[dict[str, Any]]: + ) -> ExtendedList[ExtendedDict]: """List service accounts in a project. Args: @@ -458,7 +477,7 @@ def list_service_accounts( Returns: List of service account dictionaries. """ - self.logger.info(f"Listing service accounts in {project_id}") + self.logger.info(f"Listing service accounts in {safe_google_ref(project_id)}") service = self.get_iam_service() accounts: list[dict[str, Any]] = [] @@ -481,7 +500,7 @@ def list_service_accounts( if unhump_accounts: accounts = [unhump_map(a) for a in accounts] - return accounts + return self.extend_result(accounts) def create_service_account( self, @@ -489,7 +508,7 @@ def create_service_account( account_id: str, display_name: str, description: str = "", - ) -> dict[str, Any]: + ) -> ExtendedDict: """Create a service account in a project. Args: @@ -501,7 +520,9 @@ def create_service_account( Returns: Created service account dictionary. """ - self.logger.info(f"Creating service account: {account_id} in {project_id}") + safe_account = safe_google_ref(account_id) + safe_project = safe_google_ref(project_id) + self.logger.info(f"Creating service account: {safe_account} in {safe_project}") service = self.get_iam_service() result = ( @@ -520,5 +541,5 @@ def create_service_account( .execute() ) - self.logger.info(f"Created service account: {result.get('email')}") - return result + self.logger.info(f"Created service account: {safe_google_ref(result.get('email'))}") + return self.extend_result(result) diff --git a/src/extended_data/connectors/google/constants.py b/src/extended_data/connectors/google/constants.py index c92d70f..d724f4d 100644 --- a/src/extended_data/connectors/google/constants.py +++ b/src/extended_data/connectors/google/constants.py @@ -1,4 +1,4 @@ -"""Google connector constants for terraform-modules settings. +"""Google connector constants for cloud and Workspace workflows. These constants provide default configurations for Google Cloud and Workspace operations. Override these values with environment-specific configuration. @@ -12,7 +12,7 @@ # Default domain - MUST be overridden via environment variable DEFAULT_DOMAIN = os.getenv("GOOGLE_WORKSPACE_DOMAIN", "example.com") -# Full OAuth scopes matching terraform-modules for maximum compatibility +# Broad OAuth scopes for mixed Workspace, Cloud, billing, and service discovery workflows. DEFAULT_SCOPES = [ "https://mail.google.com/", "https://www.googleapis.com/auth/apps.alerts", @@ -56,7 +56,7 @@ }, } -# KMS configuration for terraform secrets +# KMS configuration defaults for infrastructure secret material. GCP_KMS = { "keyring_name": "terraform-secrets", "key_name": "terraform-key", diff --git a/src/extended_data/connectors/google/jules.py b/src/extended_data/connectors/google/jules.py index 287fd25..397c3ae 100644 --- a/src/extended_data/connectors/google/jules.py +++ b/src/extended_data/connectors/google/jules.py @@ -19,19 +19,26 @@ ) # Poll for completion - status = connector.get_session(session.name) + status = connector.get_session(session["name"]) Reference: https://developers.google.com/jules/api """ from __future__ import annotations +from collections.abc import Mapping +from contextlib import suppress from enum import Enum from typing import Any -from pydantic import BaseModel, Field +import httpx -from extended_data.connectors.base import VendorConnectorBase +from pydantic import BaseModel, Field, ValidationError + +from extended_data.connectors.base import ConnectorBase +from extended_data.connectors.google._diagnostics import safe_google_text +from extended_data.containers import ExtendedDict, ExtendedList, to_builtin +from extended_data.primitives.redaction import redact_sensitive_data __all__ = [ @@ -74,14 +81,14 @@ class Source(BaseModel): name: str = Field(..., description="Resource name (e.g., sources/github/org/repo)") id: str = Field(..., description="Source ID") - github_repo: dict | None = Field(None, alias="githubRepo") + github_repo: dict[str, Any] | None = Field(None, alias="githubRepo") class SourceContext(BaseModel): """Context for a session's source.""" source: str = Field(..., description="Source resource name") - github_repo_context: dict | None = Field(None, alias="githubRepoContext") + github_repo_context: dict[str, Any] | None = Field(None, alias="githubRepoContext") class PullRequestOutput(BaseModel): @@ -103,7 +110,7 @@ class Session(BaseModel): prompt: str = Field("", description="Original prompt") state: str | None = Field(None, description="Current state") source_context: SourceContext | None = Field(None, alias="sourceContext") - outputs: list[dict] = Field(default_factory=list, description="Session outputs") + outputs: list[dict[str, Any]] = Field(default_factory=list, description="Session outputs") @property def pull_request(self) -> PullRequestOutput | None: @@ -123,7 +130,7 @@ def __init__(self, message: str, code: int = 0, details: Any = None): self.details = details -class JulesConnector(VendorConnectorBase): +class JulesConnector(ConnectorBase): """Connector for Google Jules AI Agent API. Provides methods to interact with Jules for automated coding tasks. @@ -137,8 +144,8 @@ def __init__( api_key: str | None = None, base_url: str | None = None, timeout: float = 60.0, - **kwargs, - ): + **kwargs: Any, + ) -> None: """Initialize the Jules connector. Args: @@ -156,25 +163,121 @@ def _build_headers(self) -> dict[str, str]: "Content-Type": "application/json", } - def _handle_response(self, response) -> dict: - """Handle API response, raising on errors.""" + def _handle_response(self, response: httpx.Response, operation: str, *sensitive_values: Any) -> dict[str, Any]: + """Handle API response, raising redacted errors for API or payload failures.""" + diagnostic_values = self._response_diagnostic_values(response, *sensitive_values) if not response.is_success: - try: - error = response.json().get("error", {}) - raise JulesError( - error.get("message", response.text), - error.get("code", response.status_code), - error.get("details"), - ) - except (ValueError, KeyError) as exc: - raise JulesError(response.text, response.status_code) from exc - return response.json() + self._raise_api_error(response, operation, diagnostic_values) + + data = self._response_json(response, operation, diagnostic_values) + if not isinstance(data, Mapping): + raise self._unexpected_response_error(operation, data, response.status_code, diagnostic_values) + return to_builtin(data) + + def _raise_api_error( + self, + response: httpx.Response, + operation: str, + diagnostic_values: list[Any], + ) -> None: + """Raise a Jules API error with all details redacted.""" + try: + error_data = self._response_json(response, operation, diagnostic_values) + except JulesError: + raise JulesError(safe_google_text(response.text, diagnostic_values), response.status_code) from None + + raw_error = error_data.get("error", {}) if isinstance(error_data, Mapping) else {} + error = raw_error if isinstance(raw_error, Mapping) else {} + error_code = error.get("code", response.status_code) + if not isinstance(error_code, int): + error_code = response.status_code + + raise JulesError( + safe_google_text(error.get("message", response.text), diagnostic_values), + error_code, + redact_sensitive_data(to_builtin(error.get("details")), values=diagnostic_values), + ) + + def _response_json(self, response: httpx.Response, operation: str, diagnostic_values: list[Any]) -> Any: + """Parse JSON response content or raise a redacted malformed-response error.""" + if not response.content: + return {} + try: + return self.decode_response(response, suffix="json", as_extended=True) + except Exception: + raise self._unexpected_response_error( + operation, + response.text, + response.status_code, + diagnostic_values, + ) from None + + @staticmethod + def _unexpected_response_error( + operation: str, + data: Any, + status_code: int, + diagnostic_values: list[Any], + ) -> JulesError: + """Build a redacted malformed-response error.""" + return JulesError( + f"Unexpected Jules response for {operation}: {safe_google_text(data, diagnostic_values)}", + status_code, + ) + + def _response_diagnostic_values(self, response: httpx.Response, *sensitive_values: Any) -> list[Any]: + """Collect caller-controlled response identifiers for diagnostics redaction.""" + values: list[Any] = [self._base_url, self._api_key, *sensitive_values] + with suppress(RuntimeError): + values.append(str(response.request.url)) + return values # ========================================================================= # Sources # ========================================================================= - def list_sources(self, page_size: int = 100, page_token: str = "") -> list[Source]: + @staticmethod + def _model_payload(model: BaseModel) -> dict[str, Any]: + """Serialize a Jules model using API field aliases.""" + return model.model_dump(by_alias=True) + + def _parse_model_response( + self, + data: Any, + model_type: type[BaseModel], + operation: str, + *sensitive_values: Any, + ) -> dict[str, Any]: + """Validate one Jules response model and return a JSON payload.""" + try: + return self._model_payload(model_type.model_validate(to_builtin(data))) + except ValidationError: + raise self._unexpected_response_error( + operation, + data, + 200, + list(sensitive_values), + ) from None + + def _parse_model_list( + self, + data: Mapping[str, Any], + field_name: str, + model_type: type[BaseModel], + operation: str, + *sensitive_values: Any, + ) -> list[dict[str, Any]]: + """Validate a Jules response list and return JSON payloads.""" + items = data.get(field_name) + if not isinstance(items, list): + raise self._unexpected_response_error(operation, data, 200, list(sensitive_values)) + + try: + return [self._model_payload(model_type.model_validate(to_builtin(item))) for item in items] + except ValidationError: + raise self._unexpected_response_error(operation, data, 200, list(sensitive_values)) from None + + def list_sources(self, page_size: int = 100, page_token: str = "") -> ExtendedList[ExtendedDict]: """List available sources (connected GitHub repos). Args: @@ -184,14 +287,14 @@ def list_sources(self, page_size: int = 100, page_token: str = "") -> list[Sourc Returns: List of Source objects. """ - params = {"pageSize": page_size} + params: dict[str, Any] = {"pageSize": page_size} if page_token: params["pageToken"] = page_token response = self.get("/sources", params=params) - data = self._handle_response(response) + data = self._handle_response(response, "list_sources", params) - return [Source(**s) for s in data.get("sources", [])] + return self.extend_result(self._parse_model_list(data, "sources", Source, "list_sources", params)) # ========================================================================= # Sessions @@ -205,7 +308,7 @@ def create_session( starting_branch: str = "main", automation_mode: str = "AUTO_CREATE_PR", require_plan_approval: bool = False, - ) -> Session: + ) -> ExtendedDict: """Create a new Jules session. Args: @@ -219,7 +322,7 @@ def create_session( Returns: Created Session object. """ - body = { + body: dict[str, Any] = { "prompt": prompt, "sourceContext": { "source": source, @@ -236,11 +339,11 @@ def create_session( body["requirePlanApproval"] = True response = self.post("/sessions", json=body) - data = self._handle_response(response) + data = self._handle_response(response, "create_session", body) - return Session(**data) + return self.extend_result(self._parse_model_response(data, Session, "create_session", body)) - def get_session(self, session_name: str) -> Session: + def get_session(self, session_name: str) -> ExtendedDict: """Get a session by name. Args: @@ -254,11 +357,11 @@ def get_session(self, session_name: str) -> Session: session_name = f"sessions/{session_name}" response = self.get(f"/{session_name}") - data = self._handle_response(response) + data = self._handle_response(response, "get_session", session_name) - return Session(**data) + return self.extend_result(self._parse_model_response(data, Session, "get_session", session_name)) - def list_sessions(self, page_size: int = 20, page_token: str = "") -> list[Session]: + def list_sessions(self, page_size: int = 20, page_token: str = "") -> ExtendedList[ExtendedDict]: """List sessions. Args: @@ -268,16 +371,16 @@ def list_sessions(self, page_size: int = 20, page_token: str = "") -> list[Sessi Returns: List of Session objects. """ - params = {"pageSize": page_size} + params: dict[str, Any] = {"pageSize": page_size} if page_token: params["pageToken"] = page_token response = self.get("/sessions", params=params) - data = self._handle_response(response) + data = self._handle_response(response, "list_sessions", params) - return [Session(**s) for s in data.get("sessions", [])] + return self.extend_result(self._parse_model_list(data, "sessions", Session, "list_sessions", params)) - def approve_plan(self, session_name: str) -> Session: + def approve_plan(self, session_name: str) -> ExtendedDict: """Approve the plan for a session that requires approval. Args: @@ -290,16 +393,17 @@ def approve_plan(self, session_name: str) -> Session: session_name = f"sessions/{session_name}" response = self.post(f"/{session_name}:approvePlan") - self._handle_response(response) + self._handle_response(response, "approve_plan", session_name) # API returns empty on success, fetch updated session return self.get_session(session_name) - def add_user_response(self, session_name: str, message: str = "") -> Session: + def add_user_response(self, session_name: str, message: str) -> ExtendedDict: """Add a follow-up message to a session or resume it. - Note: The Jules API uses :sendMessage endpoint. An empty body - resumes a paused session. A message can be included in certain states. + Note: The Jules API uses the :sendMessage endpoint with a required + prompt body. The response body is empty on success, so this method + fetches and returns the updated session. Args: session_name: Full resource name. @@ -308,23 +412,28 @@ def add_user_response(self, session_name: str, message: str = "") -> Session: Returns: Updated Session object. """ + if not isinstance(message, str) or not message.strip(): + msg = "Jules sendMessage requires a non-empty prompt" + raise ValueError(msg) + if not session_name.startswith("sessions/"): session_name = f"sessions/{session_name}" - # The API uses sendMessage, not addUserResponse - response = self.post(f"/{session_name}:sendMessage", json={}) - self._handle_response(response) + body = {"prompt": message} + response = self.post(f"/{session_name}:sendMessage", json=body) + self._handle_response(response, "add_user_response", session_name, body) # API returns empty on success, fetch updated session return self.get_session(session_name) - def resume_session(self, session_name: str) -> Session: - """Resume a paused or awaiting session. + def resume_session(self, session_name: str, message: str) -> ExtendedDict: + """Resume a paused or awaiting session by sending a follow-up prompt. Args: session_name: Full resource name. + message: User prompt to send to the session. Returns: Updated Session object. """ - return self.add_user_response(session_name) + return self.add_user_response(session_name, message) diff --git a/src/extended_data/connectors/google/services.py b/src/extended_data/connectors/google/services.py index 806a7b8..3511f21 100644 --- a/src/extended_data/connectors/google/services.py +++ b/src/extended_data/connectors/google/services.py @@ -6,9 +6,79 @@ from __future__ import annotations -from typing import Any +import datetime as dt -from extended_data import unhump_map +from collections.abc import Mapping, MutableMapping +from typing import TYPE_CHECKING, Any + +from extended_data.connectors.google._diagnostics import safe_google_ref, safe_google_text +from extended_data.containers import ExtendedDict, ExtendedList +from extended_data.primitives import unhump_map + + +_PROJECT_ACTIVITY_TIME_FIELDS = ( + "lastActivityTime", + "lastActiveTime", + "last_activity_time", + "last_active_time", + "updateTime", + "createTime", +) + + +def _has_http_status(exc: BaseException, status: int) -> bool: + """Return whether an exception exposes a Google-style HTTP response status.""" + return getattr(getattr(exc, "resp", None), "status", None) == status + + +def _parse_project_activity_time(value: Any) -> dt.datetime | None: + """Parse a Google-style timestamp into an aware UTC datetime.""" + if value is None: + return None + + normalized = str(value).strip() + if not normalized: + return None + + if normalized.endswith("Z"): + normalized = f"{normalized[:-1]}+00:00" + + try: + parsed = dt.datetime.fromisoformat(normalized) + except ValueError: + return None + + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=dt.timezone.utc) + return parsed.astimezone(dt.timezone.utc) + + +def _latest_project_activity_time(project_data: Mapping[str, Any]) -> dt.datetime | None: + """Return the latest activity timestamp available on project metadata.""" + timestamps = [ + parsed + for field in _PROJECT_ACTIVITY_TIME_FIELDS + if (parsed := _parse_project_activity_time(project_data.get(field))) is not None + ] + return max(timestamps) if timestamps else None + + +def _project_activity_is_stale( + project_data: Mapping[str, Any], + *, + days_since_activity: int, + now: dt.datetime | None = None, +) -> bool: + """Return whether project metadata indicates activity older than the threshold.""" + activity_time = _latest_project_activity_time(project_data) + if activity_time is None: + return True + + reference_time = now or dt.datetime.now(dt.timezone.utc) + if reference_time.tzinfo is None: + reference_time = reference_time.replace(tzinfo=dt.timezone.utc) + cutoff = reference_time.astimezone(dt.timezone.utc) - dt.timedelta(days=days_since_activity) + return activity_time <= cutoff class GoogleServicesMixin: @@ -25,6 +95,27 @@ class GoogleServicesMixin: - logger """ + if TYPE_CHECKING: + logger: Any + + def get_compute_service(self) -> Any: ... + + def get_container_service(self) -> Any: ... + + def get_storage_service(self) -> Any: ... + + def get_sqladmin_service(self) -> Any: ... + + def get_pubsub_service(self) -> Any: ... + + def get_serviceusage_service(self) -> Any: ... + + def get_cloudkms_service(self) -> Any: ... + + def get_cloud_resource_manager_service(self) -> Any: ... + + def extend_result(self, value: Any) -> Any: ... + # ========================================================================= # Compute Engine # ========================================================================= @@ -34,7 +125,7 @@ def list_compute_instances( project_id: str, zone: str | None = None, unhump_instances: bool = False, - ) -> list[dict[str, Any]]: + ) -> ExtendedList[ExtendedDict]: """List Compute Engine instances in a project. Args: @@ -45,7 +136,8 @@ def list_compute_instances( Returns: List of instance dictionaries. """ - self.logger.info(f"Listing Compute Engine instances in {project_id}") + safe_project = safe_google_ref(project_id) + self.logger.info(f"Listing Compute Engine instances in {safe_project}") service = self.get_compute_service() instances: list[dict[str, Any]] = [] @@ -54,11 +146,11 @@ def list_compute_instances( # List instances in specific zone page_token = None while True: - params: dict[str, Any] = {"project": project_id, "zone": zone} + zone_params: dict[str, Any] = {"project": project_id, "zone": zone} if page_token: - params["pageToken"] = page_token + zone_params["pageToken"] = page_token - response = service.instances().list(**params).execute() + response = service.instances().list(**zone_params).execute() instances.extend(response.get("items", [])) page_token = response.get("nextPageToken") @@ -68,11 +160,11 @@ def list_compute_instances( # Aggregate list across all zones page_token = None while True: - params: dict[str, Any] = {"project": project_id} + aggregate_params: dict[str, Any] = {"project": project_id} if page_token: - params["pageToken"] = page_token + aggregate_params["pageToken"] = page_token - response = service.instances().aggregatedList(**params).execute() + response = service.instances().aggregatedList(**aggregate_params).execute() for zone_data in response.get("items", {}).values(): instances.extend(zone_data.get("instances", [])) @@ -85,7 +177,7 @@ def list_compute_instances( if unhump_instances: instances = [unhump_map(i) for i in instances] - return instances + return self.extend_result(instances) # ========================================================================= # Google Kubernetes Engine @@ -96,7 +188,7 @@ def list_gke_clusters( project_id: str, location: str = "-", unhump_clusters: bool = False, - ) -> list[dict[str, Any]]: + ) -> ExtendedList[ExtendedDict]: """List GKE clusters in a project. Args: @@ -107,7 +199,8 @@ def list_gke_clusters( Returns: List of cluster dictionaries. """ - self.logger.info(f"Listing GKE clusters in {project_id}") + safe_project = safe_google_ref(project_id) + self.logger.info(f"Listing GKE clusters in {safe_project}") service = self.get_container_service() parent = f"projects/{project_id}/locations/{location}" @@ -119,14 +212,14 @@ def list_gke_clusters( if unhump_clusters: clusters = [unhump_map(c) for c in clusters] - return clusters + return self.extend_result(clusters) def get_gke_cluster( self, project_id: str, location: str, cluster_id: str, - ) -> dict[str, Any] | None: + ) -> ExtendedDict | None: """Get a specific GKE cluster. Args: @@ -143,10 +236,10 @@ def get_gke_cluster( name = f"projects/{project_id}/locations/{location}/clusters/{cluster_id}" try: - return service.projects().locations().clusters().get(name=name).execute() + return self.extend_result(service.projects().locations().clusters().get(name=name).execute()) except HttpError as e: if e.resp.status == 404: - self.logger.warning(f"GKE cluster not found: {cluster_id}") + self.logger.warning(f"GKE cluster not found: {safe_google_ref(cluster_id)}") return None raise @@ -158,7 +251,7 @@ def list_storage_buckets( self, project_id: str, unhump_buckets: bool = False, - ) -> list[dict[str, Any]]: + ) -> ExtendedList[ExtendedDict]: """List Cloud Storage buckets in a project. Args: @@ -168,7 +261,8 @@ def list_storage_buckets( Returns: List of bucket dictionaries. """ - self.logger.info(f"Listing Cloud Storage buckets in {project_id}") + safe_project = safe_google_ref(project_id) + self.logger.info(f"Listing Cloud Storage buckets in {safe_project}") service = self.get_storage_service() buckets: list[dict[str, Any]] = [] @@ -191,7 +285,7 @@ def list_storage_buckets( if unhump_buckets: buckets = [unhump_map(b) for b in buckets] - return buckets + return self.extend_result(buckets) # ========================================================================= # Cloud SQL @@ -201,7 +295,7 @@ def list_sql_instances( self, project_id: str, unhump_instances: bool = False, - ) -> list[dict[str, Any]]: + ) -> ExtendedList[ExtendedDict]: """List Cloud SQL instances in a project. Args: @@ -211,7 +305,8 @@ def list_sql_instances( Returns: List of SQL instance dictionaries. """ - self.logger.info(f"Listing Cloud SQL instances in {project_id}") + safe_project = safe_google_ref(project_id) + self.logger.info(f"Listing Cloud SQL instances in {safe_project}") service = self.get_sqladmin_service() instances: list[dict[str, Any]] = [] @@ -234,7 +329,7 @@ def list_sql_instances( if unhump_instances: instances = [unhump_map(i) for i in instances] - return instances + return self.extend_result(instances) # ========================================================================= # Pub/Sub @@ -244,7 +339,7 @@ def list_pubsub_topics( self, project_id: str, unhump_topics: bool = False, - ) -> list[dict[str, Any]]: + ) -> ExtendedList[ExtendedDict]: """List Pub/Sub topics in a project. Args: @@ -254,7 +349,8 @@ def list_pubsub_topics( Returns: List of topic dictionaries. """ - self.logger.info(f"Listing Pub/Sub topics in {project_id}") + safe_project = safe_google_ref(project_id) + self.logger.info(f"Listing Pub/Sub topics in {safe_project}") service = self.get_pubsub_service() topics: list[dict[str, Any]] = [] @@ -277,13 +373,13 @@ def list_pubsub_topics( if unhump_topics: topics = [unhump_map(t) for t in topics] - return topics + return self.extend_result(topics) def list_pubsub_subscriptions( self, project_id: str, unhump_subscriptions: bool = False, - ) -> list[dict[str, Any]]: + ) -> ExtendedList[ExtendedDict]: """List Pub/Sub subscriptions in a project. Args: @@ -293,7 +389,8 @@ def list_pubsub_subscriptions( Returns: List of subscription dictionaries. """ - self.logger.info(f"Listing Pub/Sub subscriptions in {project_id}") + safe_project = safe_google_ref(project_id) + self.logger.info(f"Listing Pub/Sub subscriptions in {safe_project}") service = self.get_pubsub_service() subscriptions: list[dict[str, Any]] = [] @@ -316,7 +413,7 @@ def list_pubsub_subscriptions( if unhump_subscriptions: subscriptions = [unhump_map(s) for s in subscriptions] - return subscriptions + return self.extend_result(subscriptions) # ========================================================================= # Service Usage (Enabled APIs) @@ -326,7 +423,7 @@ def list_enabled_services( self, project_id: str, unhump_services: bool = False, - ) -> list[dict[str, Any]]: + ) -> ExtendedList[ExtendedDict]: """List enabled APIs/services in a project. Args: @@ -336,7 +433,8 @@ def list_enabled_services( Returns: List of service dictionaries. """ - self.logger.info(f"Listing enabled services in {project_id}") + safe_project = safe_google_ref(project_id) + self.logger.info(f"Listing enabled services in {safe_project}") service = self.get_serviceusage_service() services: list[dict[str, Any]] = [] @@ -362,13 +460,13 @@ def list_enabled_services( if unhump_services: services = [unhump_map(s) for s in services] - return services + return self.extend_result(services) def enable_service( self, project_id: str, service_name: str, - ) -> dict[str, Any]: + ) -> ExtendedDict: """Enable an API/service in a project. Args: @@ -378,21 +476,23 @@ def enable_service( Returns: Operation response dictionary. """ - self.logger.info(f"Enabling service {service_name} in {project_id}") + safe_project = safe_google_ref(project_id) + safe_service_name = safe_google_ref(service_name) + self.logger.info(f"Enabling service {safe_service_name} in {safe_project}") service = self.get_serviceusage_service() name = f"projects/{project_id}/services/{service_name}" result = service.services().enable(name=name).execute() - self.logger.info(f"Enabled service {service_name}") - return result + self.logger.info(f"Enabled service {safe_service_name}") + return self.extend_result(result) def disable_service( self, project_id: str, service_name: str, force: bool = False, - ) -> dict[str, Any]: + ) -> ExtendedDict: """Disable an API/service in a project. Args: @@ -403,7 +503,9 @@ def disable_service( Returns: Operation response dictionary. """ - self.logger.info(f"Disabling service {service_name} in {project_id}") + safe_project = safe_google_ref(project_id) + safe_service_name = safe_google_ref(service_name) + self.logger.info(f"Disabling service {safe_service_name} in {safe_project}") service = self.get_serviceusage_service() name = f"projects/{project_id}/services/{service_name}" @@ -413,14 +515,14 @@ def disable_service( result = service.services().disable(name=name, body=body).execute() - self.logger.info(f"Disabled service {service_name}") - return result + self.logger.info(f"Disabled service {safe_service_name}") + return self.extend_result(result) def batch_enable_services( self, project_id: str, service_names: list[str], - ) -> dict[str, Any]: + ) -> ExtendedDict: """Enable multiple APIs/services in a project. Args: @@ -430,7 +532,8 @@ def batch_enable_services( Returns: Operation response dictionary. """ - self.logger.info(f"Batch enabling {len(service_names)} services in {project_id}") + safe_project = safe_google_ref(project_id) + self.logger.info(f"Batch enabling {len(service_names)} services in {safe_project}") service = self.get_serviceusage_service() parent = f"projects/{project_id}" @@ -444,7 +547,7 @@ def batch_enable_services( ) self.logger.info(f"Batch enabled {len(service_names)} services") - return result + return self.extend_result(result) # ========================================================================= # Cloud KMS @@ -455,7 +558,7 @@ def list_kms_keyrings( project_id: str, location: str, unhump_keyrings: bool = False, - ) -> list[dict[str, Any]]: + ) -> ExtendedList[ExtendedDict]: """List KMS key rings in a project location. Args: @@ -466,7 +569,8 @@ def list_kms_keyrings( Returns: List of key ring dictionaries. """ - self.logger.info(f"Listing KMS key rings in {project_id}/{location}") + safe_parent = safe_google_text(f"{project_id}/{location}", project_id, location) + self.logger.info(f"Listing KMS key rings in {safe_parent}") service = self.get_cloudkms_service() keyrings: list[dict[str, Any]] = [] @@ -490,14 +594,14 @@ def list_kms_keyrings( if unhump_keyrings: keyrings = [unhump_map(k) for k in keyrings] - return keyrings + return self.extend_result(keyrings) def create_kms_keyring( self, project_id: str, location: str, keyring_id: str, - ) -> dict[str, Any]: + ) -> ExtendedDict: """Create a KMS key ring. Args: @@ -508,7 +612,9 @@ def create_kms_keyring( Returns: Created key ring dictionary. """ - self.logger.info(f"Creating KMS key ring {keyring_id} in {project_id}/{location}") + safe_parent = safe_google_text(f"{project_id}/{location}", project_id, location) + safe_keyring = safe_google_ref(keyring_id) + self.logger.info(f"Creating KMS key ring {safe_keyring} in {safe_parent}") service = self.get_cloudkms_service() parent = f"projects/{project_id}/locations/{location}" @@ -524,8 +630,8 @@ def create_kms_keyring( .execute() ) - self.logger.info(f"Created key ring {keyring_id}") - return result + self.logger.info(f"Created key ring {safe_keyring}") + return self.extend_result(result) def create_kms_key( self, @@ -535,7 +641,7 @@ def create_kms_key( key_id: str, purpose: str = "ENCRYPT_DECRYPT", algorithm: str = "GOOGLE_SYMMETRIC_ENCRYPTION", - ) -> dict[str, Any]: + ) -> ExtendedDict: """Create a KMS crypto key. Args: @@ -549,7 +655,9 @@ def create_kms_key( Returns: Created crypto key dictionary. """ - self.logger.info(f"Creating KMS key {key_id} in {keyring_id}") + safe_key = safe_google_ref(key_id) + safe_keyring = safe_google_ref(keyring_id) + self.logger.info(f"Creating KMS key {safe_key} in {safe_keyring}") service = self.get_cloudkms_service() parent = f"projects/{project_id}/locations/{location}/keyRings/{keyring_id}" @@ -571,8 +679,8 @@ def create_kms_key( .execute() ) - self.logger.info(f"Created crypto key {key_id}") - return result + self.logger.info(f"Created crypto key {safe_key}") + return self.extend_result(result) # ========================================================================= # Project Resource Summary @@ -600,55 +708,54 @@ def is_project_empty( Returns: True if the project has no resources. """ - self.logger.info(f"Checking if project {project_id} is empty") - - from googleapiclient.errors import HttpError + safe_project = safe_google_ref(project_id) + self.logger.info(f"Checking if project {safe_project} is empty") try: if check_compute: instances = self.list_compute_instances(project_id) if instances: - self.logger.info(f"Project {project_id} has {len(instances)} compute instances") + self.logger.info(f"Project {safe_project} has {len(instances)} compute instances") return False if check_gke: clusters = self.list_gke_clusters(project_id) if clusters: - self.logger.info(f"Project {project_id} has {len(clusters)} GKE clusters") + self.logger.info(f"Project {safe_project} has {len(clusters)} GKE clusters") return False if check_storage: buckets = self.list_storage_buckets(project_id) if buckets: - self.logger.info(f"Project {project_id} has {len(buckets)} storage buckets") + self.logger.info(f"Project {safe_project} has {len(buckets)} storage buckets") return False if check_sql: sql_instances = self.list_sql_instances(project_id) if sql_instances: - self.logger.info(f"Project {project_id} has {len(sql_instances)} SQL instances") + self.logger.info(f"Project {safe_project} has {len(sql_instances)} SQL instances") return False if check_pubsub: topics = self.list_pubsub_topics(project_id) if topics: - self.logger.info(f"Project {project_id} has {len(topics)} Pub/Sub topics") + self.logger.info(f"Project {safe_project} has {len(topics)} Pub/Sub topics") return False - except HttpError as e: + except Exception as e: # API might not be enabled, treat as empty for that service - if e.resp.status == 403: - self.logger.debug(f"API access denied, skipping check: {e}") + if _has_http_status(e, 403): + self.logger.debug(f"API access denied, skipping check: {safe_google_text(e, project_id)}") else: raise - self.logger.info(f"Project {project_id} appears to be empty") + self.logger.info(f"Project {safe_project} appears to be empty") return True def get_project_iam_users( self, project_id: str, - ) -> dict[str, dict[str, Any]]: + ) -> ExtendedDict: """Get IAM users (members) with access to a project. Args: @@ -657,7 +764,8 @@ def get_project_iam_users( Returns: Dictionary mapping member identifiers to their roles. """ - self.logger.info(f"Getting IAM users for project {project_id}") + safe_project = safe_google_ref(project_id) + self.logger.info(f"Getting IAM users for project {safe_project}") service = self.get_cloud_resource_manager_service() response = service.projects().getIamPolicy(resource=f"projects/{project_id}", body={}).execute() @@ -670,15 +778,15 @@ def get_project_iam_users( users[member] = {"roles": [], "member_type": member.split(":")[0]} users[member]["roles"].append(role) - self.logger.info(f"Found {len(users)} IAM members for project {project_id}") - return users + self.logger.info(f"Found {len(users)} IAM members for project {safe_project}") + return self.extend_result(users) def get_pubsub_resources_for_project( self, project_id: str, include_subscriptions: bool = True, unhump_resources: bool = False, - ) -> dict[str, Any]: + ) -> ExtendedDict: """Get all Pub/Sub topics and subscriptions for a project. Args: @@ -689,7 +797,7 @@ def get_pubsub_resources_for_project( Returns: Dictionary with 'topics' and 'subscriptions' lists. """ - self.logger.info(f"Getting Pub/Sub resources for project {project_id}") + self.logger.info(f"Getting Pub/Sub resources for project {safe_google_ref(project_id)}") topics = self.list_pubsub_topics(project_id) result: dict[str, Any] = { @@ -712,41 +820,46 @@ def get_pubsub_resources_for_project( f"Found {result['topic_count']} topics" + (f", {result.get('subscription_count', 0)} subscriptions" if include_subscriptions else "") ) - return result + return self.extend_result(result) def find_inactive_projects( self, - projects: dict[str, dict[str, Any]] | None = None, + projects: MutableMapping[str, MutableMapping[str, Any]] | None = None, check_resources: bool = True, days_since_activity: int = 90, - ) -> list[dict[str, Any]]: + ) -> ExtendedList[ExtendedDict]: """Find projects that appear to be inactive or dead. A project is considered inactive if: - - It has no resources (compute, GKE, storage, etc.) - Its lifecycle state is not ACTIVE + - It has no resources and no recent activity timestamp Args: projects: Pre-fetched projects dict. Fetched if not provided. check_resources: Check if projects have resources. Defaults to True. - days_since_activity: Days threshold for activity (not implemented yet). + days_since_activity: Days threshold for available project activity + timestamps. Empty projects with recent timestamps are not marked + inactive. Empty projects without activity timestamps are treated + as inactive. Returns: List of inactive project dictionaries. """ - from googleapiclient.errors import HttpError - self.logger.info("Finding inactive projects") + if days_since_activity < 0: + msg = "days_since_activity must be greater than or equal to 0." + raise ValueError(msg) + if projects is None: # Get projects from cloud module - requires GoogleCloudMixin if hasattr(self, "list_projects"): - projects = {p["projectId"]: p for p in self.list_projects()} + projects = {str(p["projectId"]): p for p in self.list_projects()} else: self.logger.warning("list_projects not available, cannot find inactive projects") - return [] + return self.extend_result([]) - inactive: list[dict[str, Any]] = [] + inactive: list[MutableMapping[str, Any]] = [] for project_id, project_data in projects.items(): lifecycle_state = project_data.get("lifecycleState", "ACTIVE") @@ -761,15 +874,26 @@ def find_inactive_projects( if check_resources: try: is_empty = self.is_project_empty(project_id) - if is_empty: - project_data["inactive_reason"] = "no_resources" + if is_empty and _project_activity_is_stale( + project_data, + days_since_activity=days_since_activity, + ): + activity_time = _latest_project_activity_time(project_data) + project_data["inactive_reason"] = ( + f"no_resources_since={activity_time.date().isoformat()}" + if activity_time is not None + else "no_resources" + ) inactive.append(project_data) - except HttpError as e: - if e.resp.status == 403: + except Exception as e: + if _has_http_status(e, 403): # Can't check, skip - self.logger.debug(f"Cannot check resources for {project_id}: {e}") + self.logger.debug( + f"Cannot check resources for {safe_google_ref(project_id)}: " + f"{safe_google_text(e, project_id)}" + ) else: raise self.logger.info(f"Found {len(inactive)} inactive projects out of {len(projects)}") - return inactive + return self.extend_result(inactive) diff --git a/src/extended_data/connectors/google/tools.py b/src/extended_data/connectors/google/tools.py index a1bbcc8..1583958 100644 --- a/src/extended_data/connectors/google/tools.py +++ b/src/extended_data/connectors/google/tools.py @@ -25,10 +25,14 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any from pydantic import BaseModel, Field +from extended_data.connectors.ai_tools import raise_unknown_tool_framework +from extended_data.containers import ExtendedDict, ExtendedList, extend_data + # ============================================================================= # Input Schemas @@ -86,7 +90,7 @@ class ListWorkspaceGroupsSchema(BaseModel): def list_projects( parent: str = "", max_results: int = 100, -) -> list[dict[str, Any]]: +) -> ExtendedList[ExtendedDict]: """List Google Cloud projects. Args: @@ -97,9 +101,9 @@ def list_projects( Returns: List of project info (project_id, name, state, parent). """ - from extended_data.connectors.google import GoogleConnectorFull + from extended_data.connectors.google import GoogleConnector - connector = GoogleConnectorFull() + connector = GoogleConnector() projects = connector.list_projects(parent=parent or None) # Limit results and extract key fields @@ -114,13 +118,13 @@ def list_projects( } ) - return result + return extend_data(result) def list_folders( parent: str, max_results: int = 100, -) -> list[dict[str, Any]]: +) -> ExtendedList[ExtendedDict]: """List folders under a parent resource. Args: @@ -130,9 +134,9 @@ def list_folders( Returns: List of folder info (name, display_name, state, parent). """ - from extended_data.connectors.google import GoogleConnectorFull + from extended_data.connectors.google import GoogleConnector - connector = GoogleConnectorFull() + connector = GoogleConnector() folders = connector.list_folders(parent=parent) # Limit results and extract key fields @@ -147,13 +151,13 @@ def list_folders( } ) - return result + return extend_data(result) def list_enabled_services( project_id: str, max_results: int = 100, -) -> list[dict[str, Any]]: +) -> ExtendedList[ExtendedDict]: """List enabled services in a Google Cloud project. Args: @@ -163,9 +167,9 @@ def list_enabled_services( Returns: List of service info (name, title, state). """ - from extended_data.connectors.google import GoogleConnectorFull + from extended_data.connectors.google import GoogleConnector - connector = GoogleConnectorFull() + connector = GoogleConnector() services = connector.list_enabled_services(project_id=project_id) # Limit results and extract key fields @@ -179,12 +183,12 @@ def list_enabled_services( } ) - return result + return extend_data(result) def list_billing_accounts( max_results: int = 100, -) -> list[dict[str, Any]]: +) -> ExtendedList[ExtendedDict]: """List Google Cloud billing accounts. Args: @@ -193,9 +197,9 @@ def list_billing_accounts( Returns: List of billing account info (name, display_name, open, master_billing_account). """ - from extended_data.connectors.google import GoogleConnectorFull + from extended_data.connectors.google import GoogleConnector - connector = GoogleConnectorFull() + connector = GoogleConnector() accounts = connector.list_billing_accounts() # Limit results and extract key fields @@ -210,13 +214,13 @@ def list_billing_accounts( } ) - return result + return extend_data(result) def list_workspace_users( domain: str = "", max_results: int = 100, -) -> list[dict[str, Any]]: +) -> ExtendedList[ExtendedDict]: """List users from Google Workspace. Args: @@ -226,35 +230,39 @@ def list_workspace_users( Returns: List of user info (email, name, full_name, suspended, org_unit_path). """ - from extended_data.connectors.google import GoogleConnectorFull + from extended_data.connectors.google import GoogleConnector - connector = GoogleConnectorFull() - users = connector.list_users( + connector = GoogleConnector() + users_raw: Any = connector.list_users( domain=domain or None, flatten_names=True, key_by_email=False, ) + users = list(users_raw.values()) if isinstance(users_raw, Mapping) else users_raw # Limit results and extract key fields - result = [] + result: list[dict[str, Any]] = [] for user in users[:max_results]: + if not isinstance(user, Mapping): + continue + name = user.get("name", {}) result.append( { "email": user.get("primaryEmail", ""), - "name": user.get("name", {}).get("fullName", "") if isinstance(user.get("name"), dict) else "", + "name": name.get("fullName", "") if isinstance(name, Mapping) else "", "full_name": user.get("full_name", ""), "suspended": user.get("suspended", False), "org_unit_path": user.get("orgUnitPath", ""), } ) - return result + return extend_data(result) def list_workspace_groups( domain: str = "", max_results: int = 100, -) -> list[dict[str, Any]]: +) -> ExtendedList[ExtendedDict]: """List groups from Google Workspace. Args: @@ -264,17 +272,20 @@ def list_workspace_groups( Returns: List of group info (email, name, description, direct_members_count). """ - from extended_data.connectors.google import GoogleConnectorFull + from extended_data.connectors.google import GoogleConnector - connector = GoogleConnectorFull() - groups = connector.list_groups( + connector = GoogleConnector() + groups_raw: Any = connector.list_groups( domain=domain or None, key_by_email=False, ) + groups = list(groups_raw.values()) if isinstance(groups_raw, Mapping) else groups_raw # Limit results and extract key fields - result = [] + result: list[dict[str, Any]] = [] for group in groups[:max_results]: + if not isinstance(group, Mapping): + continue result.append( { "email": group.get("email", ""), @@ -284,7 +295,7 @@ def list_workspace_groups( } ) - return result + return extend_data(result) # ============================================================================= @@ -345,21 +356,9 @@ def get_langchain_tools() -> list[Any]: Raises: ImportError: If langchain-core is not installed. """ - try: - from langchain_core.tools import StructuredTool - except ImportError as e: - msg = "langchain-core is required for LangChain tools.\nInstall with: pip install extended-data[langchain]" - raise ImportError(msg) from e - - return [ - StructuredTool.from_function( - func=defn["func"], - name=defn["name"], - description=defn["description"], - args_schema=defn.get("schema") or defn.get("args_schema"), - ) - for defn in TOOL_DEFINITIONS - ] + from extended_data.connectors.ai_tools import build_langchain_tools + + return build_langchain_tools(TOOL_DEFINITIONS) def get_crewai_tools() -> list[Any]: @@ -371,11 +370,9 @@ def get_crewai_tools() -> list[Any]: Raises: ImportError: If crewai is not installed. """ - try: - from crewai.tools import tool as crewai_tool - except ImportError as e: - msg = "crewai is required for CrewAI tools.\nInstall with: pip install extended-data[crewai]" - raise ImportError(msg) from e + from extended_data.connectors._optional import get_crewai_tool_decorator + + crewai_tool = get_crewai_tool_decorator() tools = [] for defn in TOOL_DEFINITIONS: @@ -407,7 +404,6 @@ def get_tools(framework: str = "auto") -> list[Any]: - "langchain": Force LangChain StructuredTools - "crewai": Force CrewAI tools - "strands": Force plain functions for Strands - - "functions": Force plain functions (alias for strands) Returns: List of tools in the appropriate format for the framework. @@ -429,10 +425,10 @@ def get_tools(framework: str = "auto") -> list[Any]: return get_langchain_tools() if framework == "crewai": return get_crewai_tools() - if framework in ("strands", "functions"): + if framework == "strands": return get_strands_tools() - raise ValueError(f"Unknown framework: {framework}. Options: auto, langchain, crewai, strands, functions") + return raise_unknown_tool_framework(framework) # ============================================================================= diff --git a/src/extended_data/connectors/google/workspace.py b/src/extended_data/connectors/google/workspace.py index b27bb04..d394b35 100644 --- a/src/extended_data/connectors/google/workspace.py +++ b/src/extended_data/connectors/google/workspace.py @@ -6,9 +6,11 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any, cast -from extended_data import unhump_map +from extended_data.connectors.google._diagnostics import safe_google_ref, safe_google_text +from extended_data.containers import ExtendedDict, ExtendedList, to_builtin +from extended_data.primitives import unhump_map class GoogleWorkspaceMixin: @@ -20,13 +22,21 @@ class GoogleWorkspaceMixin: - logger """ - def list_users( + if TYPE_CHECKING: + logger: Any + service_account_info: dict[str, Any] + + def get_admin_directory_service(self, subject: str | None = None) -> Any: ... + + def extend_result(self, value: Any) -> Any: ... + + def list_workspace_users( self, domain: str | None = None, max_results: int = 500, unhump_users: bool = False, subject: str | None = None, - ) -> list[dict[str, Any]]: + ) -> ExtendedList[ExtendedDict]: """List users from Google Workspace. Args: @@ -61,13 +71,13 @@ def list_users( if unhump_users: users = [unhump_map(u) for u in users] - return users + return self.extend_result(users) def get_user( self, user_key: str, subject: str | None = None, - ) -> dict[str, Any] | None: + ) -> ExtendedDict | None: """Get a specific user from Google Workspace. Args: @@ -82,10 +92,10 @@ def get_user( service = self.get_admin_directory_service(subject=subject) try: - return service.users().get(userKey=user_key).execute() + return self.extend_result(service.users().get(userKey=user_key).execute()) except HttpError as e: if e.resp.status == 404: - self.logger.warning(f"User not found: {user_key}") + self.logger.warning(f"User not found: {safe_google_ref(user_key)}") return None raise @@ -98,8 +108,8 @@ def create_user( change_password_at_next_login: bool = True, org_unit_path: str = "/", subject: str | None = None, - **additional_fields, - ) -> dict[str, Any]: + **additional_fields: Any, + ) -> ExtendedDict: """Create a user in Google Workspace. Args: @@ -134,16 +144,16 @@ def create_user( **additional_fields, } - result = service.users().insert(body=user_body).execute() - self.logger.info(f"Created user: {primary_email}") - return result + result = service.users().insert(body=to_builtin(user_body)).execute() + self.logger.info(f"Created user: {safe_google_ref(primary_email)}") + return self.extend_result(result) def update_user( self, user_key: str, subject: str | None = None, - **fields, - ) -> dict[str, Any]: + **fields: Any, + ) -> ExtendedDict: """Update a user in Google Workspace. Args: @@ -155,9 +165,9 @@ def update_user( Updated user dictionary. """ service = self.get_admin_directory_service(subject=subject) - result = service.users().update(userKey=user_key, body=fields).execute() - self.logger.info(f"Updated user: {user_key}") - return result + result = service.users().update(userKey=user_key, body=to_builtin(fields)).execute() + self.logger.info(f"Updated user: {safe_google_ref(user_key)}") + return self.extend_result(result) def delete_user( self, @@ -172,15 +182,15 @@ def delete_user( """ service = self.get_admin_directory_service(subject=subject) service.users().delete(userKey=user_key).execute() - self.logger.info(f"Deleted user: {user_key}") + self.logger.info(f"Deleted user: {safe_google_ref(user_key)}") - def list_groups( + def list_workspace_groups( self, domain: str | None = None, max_results: int = 200, unhump_groups: bool = False, subject: str | None = None, - ) -> list[dict[str, Any]]: + ) -> ExtendedList[ExtendedDict]: """List groups from Google Workspace. Args: @@ -215,13 +225,13 @@ def list_groups( if unhump_groups: groups = [unhump_map(g) for g in groups] - return groups + return self.extend_result(groups) def get_group( self, group_key: str, subject: str | None = None, - ) -> dict[str, Any] | None: + ) -> ExtendedDict | None: """Get a specific group from Google Workspace. Args: @@ -236,10 +246,10 @@ def get_group( service = self.get_admin_directory_service(subject=subject) try: - return service.groups().get(groupKey=group_key).execute() + return self.extend_result(service.groups().get(groupKey=group_key).execute()) except HttpError as e: if e.resp.status == 404: - self.logger.warning(f"Group not found: {group_key}") + self.logger.warning(f"Group not found: {safe_google_ref(group_key)}") return None raise @@ -249,7 +259,7 @@ def create_group( name: str, description: str = "", subject: str | None = None, - ) -> dict[str, Any]: + ) -> ExtendedDict: """Create a group in Google Workspace. Args: @@ -269,9 +279,9 @@ def create_group( "description": description, } - result = service.groups().insert(body=group_body).execute() - self.logger.info(f"Created group: {email}") - return result + result = service.groups().insert(body=to_builtin(group_body)).execute() + self.logger.info(f"Created group: {safe_google_ref(email)}") + return self.extend_result(result) def delete_group( self, @@ -286,7 +296,7 @@ def delete_group( """ service = self.get_admin_directory_service(subject=subject) service.groups().delete(groupKey=group_key).execute() - self.logger.info(f"Deleted group: {group_key}") + self.logger.info(f"Deleted group: {safe_google_ref(group_key)}") def list_group_members( self, @@ -294,7 +304,7 @@ def list_group_members( roles: list[str] | None = None, unhump_members: bool = False, subject: str | None = None, - ) -> list[dict[str, Any]]: + ) -> ExtendedList[ExtendedDict]: """List members of a Google Workspace group. Args: @@ -324,12 +334,12 @@ def list_group_members( if not page_token: break - self.logger.info(f"Retrieved {len(members)} members from group {group_key}") + self.logger.info(f"Retrieved {len(members)} members from group {safe_google_ref(group_key)}") if unhump_members: members = [unhump_map(m) for m in members] - return members + return self.extend_result(members) def add_group_member( self, @@ -337,7 +347,7 @@ def add_group_member( email: str, role: str = "MEMBER", subject: str | None = None, - ) -> dict[str, Any]: + ) -> ExtendedDict: """Add a member to a Google Workspace group. Args: @@ -356,9 +366,9 @@ def add_group_member( "role": role, } - result = service.members().insert(groupKey=group_key, body=member_body).execute() - self.logger.info(f"Added {email} to group {group_key} with role {role}") - return result + result = service.members().insert(groupKey=group_key, body=to_builtin(member_body)).execute() + self.logger.info(f"Added {safe_google_ref(email)} to group {safe_google_ref(group_key)} with role {role}") + return self.extend_result(result) def remove_group_member( self, @@ -375,14 +385,14 @@ def remove_group_member( """ service = self.get_admin_directory_service(subject=subject) service.members().delete(groupKey=group_key, memberKey=member_key).execute() - self.logger.info(f"Removed {member_key} from group {group_key}") + self.logger.info(f"Removed {safe_google_ref(member_key)} from group {safe_google_ref(group_key)}") def list_org_units( self, org_unit_path: str = "/", org_unit_type: str = "all", subject: str | None = None, - ) -> list[dict[str, Any]]: + ) -> ExtendedList[ExtendedDict]: """List organizational units in Google Workspace. Args: @@ -407,7 +417,7 @@ def list_org_units( org_units = response.get("organizationUnits", []) self.logger.info(f"Retrieved {len(org_units)} org units") - return org_units + return self.extend_result(org_units) def create_or_update_user( self, @@ -419,8 +429,8 @@ def create_or_update_user( change_password_at_next_login: bool = True, org_unit_path: str = "/", subject: str | None = None, - **additional_fields, - ) -> dict[str, Any]: + **additional_fields: Any, + ) -> ExtendedDict: """Create or update a user in Google Workspace. This method provides terraform-style idempotent user management. @@ -466,19 +476,19 @@ def create_or_update_user( existing = service.users().get(userKey=primary_email).execute() if update_if_exists: # Update existing user - result = service.users().update(userKey=primary_email, body=user_body).execute() - self.logger.info(f"Updated existing user: {primary_email}") - return result - self.logger.info(f"User already exists: {primary_email}") - return existing + result = service.users().update(userKey=primary_email, body=to_builtin(user_body)).execute() + self.logger.info(f"Updated existing user: {safe_google_ref(primary_email)}") + return self.extend_result(result) + self.logger.info(f"User already exists: {safe_google_ref(primary_email)}") + return self.extend_result(existing) except HttpError as e: if e.resp.status != 404: raise # User doesn't exist, create new - result = service.users().insert(body=user_body).execute() - self.logger.info(f"Created user: {primary_email}") - return result + result = service.users().insert(body=to_builtin(user_body)).execute() + self.logger.info(f"Created user: {safe_google_ref(primary_email)}") + return self.extend_result(result) def create_or_update_group( self, @@ -487,8 +497,8 @@ def create_or_update_group( description: str = "", update_if_exists: bool = False, subject: str | None = None, - **additional_fields, - ) -> dict[str, Any]: + **additional_fields: Any, + ) -> ExtendedDict: """Create or update a group in Google Workspace. This method provides terraform-style idempotent group management. @@ -521,26 +531,26 @@ def create_or_update_group( existing = service.groups().get(groupKey=email).execute() if update_if_exists: # Update existing group - result = service.groups().update(groupKey=email, body=group_body).execute() - self.logger.info(f"Updated existing group: {email}") - return result - self.logger.info(f"Group already exists: {email}") - return existing + result = service.groups().update(groupKey=email, body=to_builtin(group_body)).execute() + self.logger.info(f"Updated existing group: {safe_google_ref(email)}") + return self.extend_result(result) + self.logger.info(f"Group already exists: {safe_google_ref(email)}") + return self.extend_result(existing) except HttpError as e: if e.resp.status != 404: raise # Group doesn't exist, create new - result = service.groups().insert(body=group_body).execute() - self.logger.info(f"Created group: {email}") - return result + result = service.groups().insert(body=to_builtin(group_body)).execute() + self.logger.info(f"Created group: {safe_google_ref(email)}") + return self.extend_result(result) def list_available_licenses( self, customer_id: str = "my_customer", product_id: str | None = None, subject: str | None = None, - ) -> list[dict[str, Any]]: + ) -> ExtendedList[ExtendedDict]: """List available Google Workspace licenses. Args: @@ -559,7 +569,8 @@ def list_available_licenses( from google.oauth2 import service_account from googleapiclient.discovery import build - credentials = service_account.Credentials.from_service_account_info( + credentials_class = cast(Any, service_account.Credentials) + credentials = credentials_class.from_service_account_info( self.service_account_info, scopes=["https://www.googleapis.com/auth/apps.licensing"], ) @@ -609,20 +620,22 @@ def list_available_licenses( except HttpError as e: if e.resp.status == 404: # Product not available - self.logger.debug(f"Product {prod_id} not available") + self.logger.debug(f"Product {safe_google_ref(prod_id)} not available") elif e.resp.status == 403: - self.logger.debug(f"No access to product {prod_id}") + self.logger.debug(f"No access to product {safe_google_ref(prod_id)}") else: - self.logger.warning(f"Error listing licenses for {prod_id}: {e}") + self.logger.warning( + f"Error listing licenses for {safe_google_ref(prod_id)}: {safe_google_text(e, prod_id)}" + ) self.logger.info(f"Retrieved {len(licenses)} license assignments") - return licenses + return self.extend_result(licenses) def get_license_summary( self, customer_id: str = "my_customer", subject: str | None = None, - ) -> dict[str, dict[str, int]]: + ) -> ExtendedDict: """Get a summary of license usage by product. Args: @@ -647,4 +660,4 @@ def get_license_summary( summary[key] = {"assigned": 0} summary[key]["assigned"] += 1 - return summary + return self.extend_result(summary) diff --git a/src/extended_data/connectors/mcp.py b/src/extended_data/connectors/mcp.py index 994c79b..f706adc 100644 --- a/src/extended_data/connectors/mcp.py +++ b/src/extended_data/connectors/mcp.py @@ -1,7 +1,7 @@ """Unified MCP Server for Extended Data Connectors. This module provides a single MCP (Model Context Protocol) server that -exposes ALL extended data connectors as tools via the registry. +exposes registered connector data methods as tools via the registry. Usage: # Command line @@ -12,23 +12,37 @@ server = create_server() The server automatically discovers all registered connectors and exposes -their public methods as MCP tools. +methods that advertise Extended Data payload returns as MCP tools. This provides a standard MCP bridge between Python connectors and any MCP-aware -client with zero custom glue code - just standard MCP over stdio. +client without leaking raw SDK client factories or low-level HTTP helpers. """ from __future__ import annotations import builtins import inspect -import json import sys -from collections.abc import Callable -from typing import Any - -from extended_data.connectors.registry import get_connector, list_connectors +from collections.abc import Callable, Iterable, Mapping +from typing import Any, cast, get_origin, get_type_hints + +from extended_data.connectors.registry import ( + _list_connector_classes, + get_connector, + get_connector_info, + list_available_connectors, + list_connector_capabilities, + list_connector_categories, + list_connector_info, + list_connectors, + list_connectors_by_capability, + list_connectors_by_category, +) +from extended_data.connectors.surface import connector_data_methods +from extended_data.containers import to_builtin +from extended_data.io import wrap_raw_data_for_export +from extended_data.primitives.redaction import redact_sensitive_data, redact_sensitive_text def _check_mcp_installed() -> bool: @@ -41,9 +55,13 @@ def _check_mcp_installed() -> bool: return False -def _get_method_schema(method: Callable) -> dict[str, Any]: +def _get_method_schema(method: Callable[..., Any]) -> dict[str, Any]: """Generate JSON schema from method signature.""" sig = inspect.signature(method) + try: + type_hints = get_type_hints(method) + except Exception: + type_hints = {} properties = {} required = [] @@ -54,17 +72,17 @@ def _get_method_schema(method: Callable) -> dict[str, Any]: prop: dict[str, Any] = {"type": "string"} # Default # Try to get type from annotations - if param.annotation != inspect.Parameter.empty: - ann = param.annotation + ann = type_hints.get(name, param.annotation) + if ann != inspect.Parameter.empty: if ann is int: prop = {"type": "integer"} elif ann is float: prop = {"type": "number"} elif ann is bool: prop = {"type": "boolean"} - elif ann is list or (hasattr(ann, "__origin__") and ann.__origin__ is list): + elif ann is list or get_origin(ann) is list: prop = {"type": "array"} - elif ann is dict or (hasattr(ann, "__origin__") and ann.__origin__ is dict): + elif ann is dict or get_origin(ann) is dict: prop = {"type": "object"} # Get description from docstring if available @@ -90,19 +108,116 @@ def _get_method_schema(method: Callable) -> dict[str, Any]: } -def _get_public_methods(connector_class: builtins.type[Any]) -> list[tuple[str, Callable]]: - """Get public methods from a connector class (excluding dunder and private).""" - methods = [] - for name in dir(connector_class): - if name.startswith("_"): - continue - attr = getattr(connector_class, name, None) - if callable(attr) and not isinstance(attr, builtins.type): - methods.append((name, attr)) - return methods +def _get_public_methods(connector_class: builtins.type[Any]) -> list[tuple[str, Callable[..., Any]]]: + """Get public data methods from a connector class for MCP exposure.""" + return connector_data_methods(connector_class) + + +def _catalog_tool_definitions() -> dict[str, dict[str, Any]]: + """Build credential-free connector catalog MCP tools.""" + include_unavailable_schema: dict[str, Any] = { + "type": "object", + "properties": {"include_unavailable": {"type": "boolean", "default": True}}, + "required": [], + } + empty_schema: dict[str, Any] = {"type": "object", "properties": {}, "required": []} + name_schema: dict[str, Any] = { + "type": "object", + "properties": { + "name": {"type": "string"}, + "include_unavailable": {"type": "boolean", "default": True}, + }, + "required": ["name"], + } + category_schema: dict[str, Any] = { + "type": "object", + "properties": { + "category": {"type": "string"}, + "include_unavailable": {"type": "boolean", "default": True}, + }, + "required": ["category"], + } + capability_schema: dict[str, Any] = { + "type": "object", + "properties": { + "capability": {"type": "string"}, + "include_unavailable": {"type": "boolean", "default": True}, + }, + "required": ["capability"], + } + + return { + "extended_data_list_connectors": { + "description": "List Extended Data connector catalog names.", + "parameters": include_unavailable_schema, + "handler": list_connectors, + }, + "extended_data_list_available_connectors": { + "description": "List Extended Data connectors available in the current environment.", + "parameters": empty_schema, + "handler": list_available_connectors, + }, + "extended_data_list_connector_info": { + "description": "List Extended Data connector catalog metadata.", + "parameters": include_unavailable_schema, + "handler": list_connector_info, + }, + "extended_data_get_connector_info": { + "description": "Get Extended Data catalog metadata for one connector.", + "parameters": name_schema, + "handler": get_connector_info, + }, + "extended_data_list_connector_categories": { + "description": "List Extended Data connector catalog categories.", + "parameters": include_unavailable_schema, + "handler": list_connector_categories, + }, + "extended_data_list_connector_capabilities": { + "description": "List Extended Data connector catalog capabilities.", + "parameters": include_unavailable_schema, + "handler": list_connector_capabilities, + }, + "extended_data_list_connectors_by_category": { + "description": "List Extended Data connector catalog entries for a category.", + "parameters": category_schema, + "handler": list_connectors_by_category, + }, + "extended_data_list_connectors_by_capability": { + "description": "List Extended Data connector catalog entries for a capability.", + "parameters": capability_schema, + "handler": list_connectors_by_capability, + }, + } + + +def _jsonable_tool_result(result: Any) -> Any: + """Lower connector tool results to JSON-compatible Python data.""" + if hasattr(result, "model_dump"): + result = result.model_dump() + elif isinstance(result, Iterable) and not isinstance(result, (str, bytes, bytearray, Mapping)): + result = [item.model_dump() if hasattr(item, "model_dump") else item for item in result] + result = to_builtin(result) + if isinstance(result, set | frozenset): + result = [to_builtin(item) for item in result] + return redact_sensitive_data(result) + + +def _tool_error_text(error: Exception, values: Iterable[Any] | None = None) -> str: + """Return an MCP-safe error string without raw secret values.""" + return f"Error: {type(error).__name__}: {redact_sensitive_text(error, values=values)}" + +def _unknown_tool_text(name: str) -> str: + """Return an MCP-safe unknown-tool diagnostic.""" + return f"Unknown tool: {redact_sensitive_text(name)}" -def create_server(): + +def _tool_result_text(result: Any) -> str: + """Return a serialized MCP tool result through the shared export boundary.""" + return wrap_raw_data_for_export(_jsonable_tool_result(result), allow_encoding="json", indent_2=True, default=str) + + +def create_server() -> Any: """Create the unified MCP server with all registered connectors.""" try: from mcp.server import Server @@ -115,9 +230,10 @@ def create_server(): # Build tool registry from all connectors tools: dict[str, dict[str, Any]] = {} + tools.update(_catalog_tool_definitions()) # Discover all connectors - connectors = list_connectors() + connectors = _list_connector_classes() for connector_name, connector_class in connectors.items(): # Get public methods @@ -148,7 +264,10 @@ def create_server(): "parameters": schema, } - @server.list_tools() + tool_decorator = cast(Callable[[], Callable[[Callable[..., Any]], Callable[..., Any]]], server.list_tools) + call_decorator = cast(Callable[[], Callable[[Callable[..., Any]], Callable[..., Any]]], server.call_tool) + + @tool_decorator() async def list_tools() -> list[Tool]: """Return all available tools.""" return [ @@ -156,13 +275,23 @@ async def list_tools() -> list[Tool]: for name, tool in tools.items() ] - @server.call_tool() - async def call_tool(name: str, arguments: dict) -> list[TextContent]: + @call_decorator() + async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: """Execute a tool and return results.""" if name not in tools: - return [TextContent(type="text", text=f"Unknown tool: {name}")] + return [TextContent(type="text", text=_unknown_tool_text(name))] tool = tools[name] + handler = tool.get("handler") + if callable(handler): + try: + result = handler(**arguments) + if inspect.iscoroutine(result): + result = await result + return [TextContent(type="text", text=_tool_result_text(result))] + except Exception as e: + return [TextContent(type="text", text=_tool_error_text(e, arguments.values()))] + connector_name = tool["connector"] method_name = tool["method"] @@ -178,16 +307,10 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]: if inspect.iscoroutine(result): result = await result - # Convert Pydantic models to dict - if hasattr(result, "model_dump"): - result = result.model_dump() - elif hasattr(result, "__iter__") and not isinstance(result, (str, dict)): - result = [r.model_dump() if hasattr(r, "model_dump") else r for r in result] - - return [TextContent(type="text", text=json.dumps(result, indent=2, default=str))] + return [TextContent(type="text", text=_tool_result_text(result))] except Exception as e: - return [TextContent(type="text", text=f"Error: {type(e).__name__}: {e}")] + return [TextContent(type="text", text=_tool_error_text(e, arguments.values()))] return server @@ -203,7 +326,7 @@ def main() -> int: server = create_server() - async def run(): + async def run() -> None: async with stdio_server() as (read_stream, write_stream): await server.run(read_stream, write_stream, server.create_initialization_options()) diff --git a/src/extended_data/connectors/meshy/README.md b/src/extended_data/connectors/meshy/README.md index 41527e4..dc7c5c6 100644 --- a/src/extended_data/connectors/meshy/README.md +++ b/src/extended_data/connectors/meshy/README.md @@ -1,96 +1,120 @@ -# Meshy SDK +# Meshy Connector -Modular Python package for generating game assets via Meshy API. +Meshy support is part of `extended-data` and lives under +`extended_data.connectors.meshy`. It provides functional API helpers, a +`MeshyConnector` fabric integration, job orchestration, webhook handling, AI tool +adapters, and an MCP server. -## Features +## Install -- **All Endpoints**: Text-to-3D, Text-to-Texture, Image-to-3D -- **Rate Limiting**: Automatic 429 handling with exponential backoff -- **Type Safety**: Pydantic models for all API types -- **Job Orchestration**: High-level `AssetGenerator` for game asset workflows -- **Auto-Download**: Fetches GLB models, PBR textures, thumbnails -- **Metadata**: JSON manifests for ECS integration - -## Quick Start - -```python -from tools.meshy import AssetGenerator, otter_player_spec +```bash +pip install "extended-data[meshy]" +``` -# Generate player character -generator = AssetGenerator(output_root="client/public") -manifest = generator.generate_model(otter_player_spec(), wait=True) +Install the MCP extra too when running `meshy-mcp` or wiring Meshy tools into an +MCP client: -print(f"Model: {manifest.model_path}") -print(f"Textures: {manifest.texture_paths}") +```bash +pip install "extended-data[meshy,mcp]" ``` -## CLI Usage +Use the `vector` extra only when you need local vector search over generated +asset metadata: ```bash -python3 scripts/generate_assets.py +pip install "extended-data[meshy,vector]" ``` -Generates 6 core assets: -- Player otter -- 2 NPC otters -- Bass fish -- Cattail reeds -- Wooden dock +The `vector` extra installs `sqlite-vec` for local similarity search. +Embedding generation through `get_embedding()` uses `sentence-transformers` +only when users install it independently after reviewing its `torch` dependency +tree. -Assets output to `client/public/models/` with manifests. +## Functional API -## API +```python +from extended_data.connectors.meshy import text3d +from extended_data.connectors.meshy.models import ArtStyle, Text3DRequest + +task_id = text3d.create( + Text3DRequest( + mode="preview", + prompt="game-ready low-poly wooden crate with metal bands", + art_style=ArtStyle.REALISTIC, + target_polycount=5000, + enable_pbr=True, + ) +) + +result = text3d.poll(task_id) +print(result["status"]) +``` -### Client +The package also exposes `image3d`, `rigging`, `animate`, and `retexture` +modules from `extended_data.connectors.meshy`. + +## Connector Fabric ```python -from tools.meshy import MeshyClient, Text3DRequest, ArtStyle +from extended_data import ConnectorFabric +from extended_data.connectors.meshy import create_meshy_logger + +fabric = ConnectorFabric(inputs={"MESHY_API_KEY": "..."}, from_environment=False) +meshy = fabric.get_connector("meshy") +logger = create_meshy_logger(default_storage_marker="asset-generation") +``` -client = MeshyClient() # Uses MESHY_API_KEY env var +Meshy logging helpers return the same `extended_data.logging.Logging` type as +the rest of the package; they do not configure global Python logging at import +time. -# Create task -task_id = client.create_text_to_3d(Text3DRequest( - prompt="anthropomorphic otter character", - art_style=ArtStyle.REALISTIC, - target_polycount=15000, - enable_pbr=True -)) +## Job Orchestration -# Poll until complete -result = client.poll_until_complete(task_id, task_type="text-to-3d") -client.download_file(result.model_urls.glb, "output.glb") +```python +from extended_data.connectors.meshy.jobs import AssetGenerator, example_character_spec + +generator = AssetGenerator(output_root="client/public") +manifest = generator.generate_model(example_character_spec(), wait=True) + +print(manifest["model_path"]) ``` -### Asset Generator +Built-in example specs are available as: + +- `example_character_spec()` +- `example_prop_spec()` +- `example_environment_spec()` + +## Webhooks + +`WebhookHandler` can verify raw request bodies before parsing or mutating task +state. Configure a shared secret and pass the raw body plus the signature header +value to `handle_signed_webhook()`: ```python -from tools.meshy import AssetGenerator, GameAssetSpec, AssetIntent, ArtStyle - -spec = GameAssetSpec( - intent=AssetIntent.CREATURE_PREY, - description="realistic marsh frog, green skin, sitting pose", - art_style=ArtStyle.REALISTIC, - target_polycount=5000, - output_path="models/creatures" -) +from extended_data.connectors.meshy.webhooks import WebhookHandler -generator = AssetGenerator() -manifest = generator.generate_model(spec, wait=True) +handler = WebhookHandler(repository=repo, webhook_secret="shared-secret") +result = handler.handle_signed_webhook(raw_body, request.headers["X-Webhook-Signature"]) ``` -### Preset Specs +Signatures are HMAC-SHA256 over the raw payload bytes. Hex, Base64, URL-safe +Base64, and `sha256=`-prefixed values are accepted. If you do not configure a +secret, `verify_signature()` returns `False` instead of accepting unsigned +payloads. + +## Tools And MCP -Pre-configured specs for common assets: +```python +from extended_data.connectors.meshy.tools import get_langchain_tools, get_strands_tools, get_tools -- `otter_player_spec()` - Player character -- `otter_npc_male_spec()` - Male NPC -- `otter_npc_female_spec()` - Female NPC -- `fish_bass_spec()` - Bass fish -- `cattail_reeds_spec()` - Marsh vegetation -- `wooden_dock_spec()` - Dock structure +tool_definitions = get_tools() +langchain_tools = get_langchain_tools() +strands_tools = get_strands_tools() +``` -## Dependencies +Run the Meshy MCP server with: -- `httpx` - HTTP client -- `tenacity` - Retry logic -- `pydantic` - Type validation +```bash +meshy-mcp +``` diff --git a/src/extended_data/connectors/meshy/__init__.py b/src/extended_data/connectors/meshy/__init__.py index 9b466a3..30512f7 100644 --- a/src/extended_data/connectors/meshy/__init__.py +++ b/src/extended_data/connectors/meshy/__init__.py @@ -13,13 +13,13 @@ model = image3d.generate("https://example.com/image.png") # Rig for animation - rigged = rigging.rig(model.id) + rigged = rigging.rig(model["id"]) # Apply animation - animated = animate.apply(rigged.id, animation_id=0) + animated = animate.apply(rigged["id"], animation_id=0) # Retexture - retextured = retexture.apply(model.id, "golden with gems") + retextured = retexture.apply(model["id"], "golden with gems") # LangChain tools from extended_data.connectors.meshy.tools import get_tools @@ -40,6 +40,7 @@ from extended_data.connectors.meshy import animate, base, image3d, retexture, rigging, text3d from extended_data.connectors.meshy.base import MeshyAPIError, RateLimitError from extended_data.connectors.meshy.connector import MeshyConnector +from extended_data.connectors.meshy.logging import MESHY_LOGGER_NAME, MESHY_STORAGE_MARKER, create_meshy_logger from extended_data.connectors.meshy.tools import ( get_crewai_tools, get_langchain_tools, @@ -49,18 +50,17 @@ __all__ = [ - # Errors + "MESHY_LOGGER_NAME", + "MESHY_STORAGE_MARKER", "MeshyAPIError", - # Connector "MeshyConnector", "RateLimitError", - # API modules (functional interface) "animate", "base", + "create_meshy_logger", "get_crewai_tools", "get_langchain_tools", "get_strands_tools", - # Tools "get_tools", "image3d", "retexture", diff --git a/src/extended_data/connectors/meshy/animate.py b/src/extended_data/connectors/meshy/animate.py index a215e30..8a66db7 100644 --- a/src/extended_data/connectors/meshy/animate.py +++ b/src/extended_data/connectors/meshy/animate.py @@ -17,9 +17,10 @@ from extended_data.connectors.meshy import base from extended_data.connectors.meshy.models import AnimationRequest, AnimationResult, TaskStatus +from extended_data.containers import ExtendedDict, ExtendedString -def create(request: AnimationRequest) -> str: +def create(request: AnimationRequest) -> ExtendedString: """Create animation task. Returns task_id.""" response = base.request( "POST", @@ -27,28 +28,27 @@ def create(request: AnimationRequest) -> str: version="v1", json=request.model_dump(exclude_none=True), ) - return response.json().get("result") + return base.task_id_from_response(response) -def get(task_id: str) -> AnimationResult: +def get(task_id: str) -> ExtendedDict: """Get task status.""" response = base.request("GET", f"animations/{task_id}", version="v1") - return AnimationResult(**response.json()) + return base.task_payload_from_response(response, AnimationResult, "animations") -def poll(task_id: str, interval: float = 5.0, timeout: float = 600.0) -> AnimationResult: +def poll(task_id: str, interval: float = 5.0, timeout: float = 600.0) -> ExtendedDict: """Poll until complete or failed.""" start = time.time() while True: result = get(task_id) - if result.status == TaskStatus.SUCCEEDED: + status = result.get("status") + if status == TaskStatus.SUCCEEDED: return result - if result.status == TaskStatus.FAILED: - error = getattr(result, "task_error", {}) - msg = error.get("message", "Unknown error") if isinstance(error, dict) else str(error) - msg = f"Task failed: {msg}" - raise RuntimeError(msg) - if result.status == TaskStatus.EXPIRED: + if status == TaskStatus.FAILED: + error = result.get("task_error") or result.get("error") + raise RuntimeError(base.task_failure_message(error)) + if status == TaskStatus.EXPIRED: msg = "Task expired" raise RuntimeError(msg) if time.time() - start > timeout: @@ -64,7 +64,7 @@ def apply( loop: bool = True, frame_rate: int = 30, wait: bool = True, -) -> AnimationResult | str: +) -> ExtendedDict | ExtendedString: """Apply animation to a rigged model. Args: @@ -75,7 +75,7 @@ def apply( wait: Wait for completion (default True) Returns: - AnimationResult if wait=True, task_id if wait=False + Extended result payload if wait=True, extended task_id if wait=False. """ request = AnimationRequest( rig_task_id=rigged_task_id, @@ -89,4 +89,4 @@ def apply( if not wait: return task_id - return poll(task_id) + return poll(str(task_id)) diff --git a/src/extended_data/connectors/meshy/animations.py b/src/extended_data/connectors/meshy/animations.py index be9d2ff..5a2dae6 100644 --- a/src/extended_data/connectors/meshy/animations.py +++ b/src/extended_data/connectors/meshy/animations.py @@ -2,8 +2,11 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import asdict, dataclass from enum import Enum +from typing import cast + +from extended_data.containers import ExtendedDict, ExtendedList, extend_data # This file is auto-generated by scripts/sync_animations.py @@ -4866,21 +4869,27 @@ def _populate_animation_sets() -> None: _populate_animation_sets() -def get_animations_by_category(category: AnimationCategory) -> list[AnimationMeta]: +def get_animations_by_category(category: AnimationCategory) -> ExtendedList[ExtendedDict]: """Get all animations in a category.""" - return [anim for anim in ANIMATIONS.values() if anim.category == category.value] + return cast( + ExtendedList[ExtendedDict], + extend_data([asdict(anim) for anim in ANIMATIONS.values() if anim.category == category.value]), + ) def get_animations_by_subcategory( subcategory: AnimationSubcategory, -) -> list[AnimationMeta]: +) -> ExtendedList[ExtendedDict]: """Get all animations in a subcategory.""" - return [anim for anim in ANIMATIONS.values() if anim.subcategory == subcategory.value] + return cast( + ExtendedList[ExtendedDict], + extend_data([asdict(anim) for anim in ANIMATIONS.values() if anim.subcategory == subcategory.value]), + ) -def get_animation(action_id: int) -> AnimationMeta: +def get_animation(action_id: int) -> ExtendedDict: """Get animation by ID.""" if action_id not in ANIMATIONS: msg = f"Animation ID {action_id} not found" raise ValueError(msg) - return ANIMATIONS[action_id] + return cast(ExtendedDict, extend_data(asdict(ANIMATIONS[action_id]))) diff --git a/src/extended_data/connectors/meshy/base.py b/src/extended_data/connectors/meshy/base.py index 893b0cb..4390c94 100644 --- a/src/extended_data/connectors/meshy/base.py +++ b/src/extended_data/connectors/meshy/base.py @@ -11,13 +11,21 @@ from __future__ import annotations +import threading import time +from collections.abc import Mapping +from typing import Any, cast + import httpx +from pydantic import BaseModel, ValidationError from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential +from extended_data.containers import ExtendedDict, ExtendedString, extend_data, to_builtin from extended_data.inputs import InputProvider +from extended_data.io.files import decode_file +from extended_data.primitives.redaction import redact_sensitive_text class RateLimitError(Exception): @@ -36,6 +44,7 @@ def __init__(self, message: str, status_code: int | None = None): _client: httpx.Client | None = None _inputs: InputProvider | None = None _last_request_time: float = 0 +_rate_limit_lock = threading.Lock() _min_request_interval: float = 0.5 # 500ms between requests BASE_URL = "https://api.meshy.ai" @@ -49,7 +58,7 @@ def _get_inputs() -> InputProvider: return _inputs -def configure(api_key: str | None = None, **kwargs) -> None: +def configure(api_key: str | None = None, **kwargs: Any) -> None: """Configure Meshy API credentials. Args: @@ -79,7 +88,7 @@ def get_client() -> httpx.Client: return _client -def close(): +def close() -> None: """Close the HTTP client.""" global _client if _client: @@ -87,14 +96,9 @@ def close(): _client = None -def _rate_limit(): +def _rate_limit() -> None: """Simple rate limiting with thread safety.""" - import threading - - global _last_request_time, _rate_limit_lock - - if "_rate_limit_lock" not in globals(): - _rate_limit_lock = threading.Lock() + global _last_request_time with _rate_limit_lock: now = time.time() @@ -112,6 +116,46 @@ def _headers() -> dict[str, str]: } +def task_failure_message(error: Any) -> str: + """Return a public, redacted Meshy task failure message.""" + if isinstance(error, Mapping): + message = error.get("message") or error.get("error") or "Unknown error" + else: + message = error or "Unknown error" + return f"Task failed: {redact_sensitive_text(message)}" + + +def unexpected_response_message(data: Any) -> str: + """Return a public, redacted unexpected-response diagnostic.""" + return f"Unexpected API response: missing 'result' key. Response: {redact_sensitive_text(data)}" + + +def _decode_response_json(response: httpx.Response) -> Any: + """Decode a Meshy JSON response through the shared data boundary.""" + if not response.content: + return None + return decode_file(response.content, suffix="json", as_extended=True) + + +def task_id_from_response(response: httpx.Response) -> ExtendedString: + """Extract a non-empty Meshy task id from a create/refine response.""" + data = _decode_response_json(response) + result = data.get("result") if isinstance(data, Mapping) else None + if not isinstance(result, (str, ExtendedString)) or not str(result).strip(): + raise RuntimeError(unexpected_response_message(data)) + return ExtendedString(str(result)) + + +def task_payload_from_response(response: httpx.Response, model_type: type[BaseModel], endpoint: str) -> ExtendedDict: + """Validate a Meshy task payload and return a promoted public mapping.""" + data = _decode_response_json(response) + try: + result = model_type.model_validate(to_builtin(data)) + except ValidationError: + raise RuntimeError(f"Unexpected API response for {endpoint}: {redact_sensitive_text(data)}") from None + return cast(ExtendedDict, extend_data(result.model_dump(mode="json"))) + + @retry( retry=retry_if_exception_type((RateLimitError, httpx.TimeoutException)), stop=stop_after_attempt(5), @@ -122,7 +166,7 @@ def request( endpoint: str, *, version: str = "v2", - **kwargs, + **kwargs: Any, ) -> httpx.Response: """Make HTTP request with retries and rate limiting. @@ -161,7 +205,7 @@ def request( # Raise on 4xx if response.status_code >= 400: - msg = f"API error: {response.text}" + msg = f"API error: {redact_sensitive_text(response.text)}" raise MeshyAPIError( msg, status_code=response.status_code, diff --git a/src/extended_data/connectors/meshy/connector.py b/src/extended_data/connectors/meshy/connector.py index 010db0c..38a0024 100644 --- a/src/extended_data/connectors/meshy/connector.py +++ b/src/extended_data/connectors/meshy/connector.py @@ -8,11 +8,12 @@ from typing import Any -from extended_data.connectors.base import VendorConnectorBase +from extended_data.connectors.base import ConnectorBase from extended_data.connectors.meshy import animate, image3d, retexture, rigging, text3d +from extended_data.containers import ExtendedDict, ExtendedString -class MeshyConnector(VendorConnectorBase): +class MeshyConnector(ConnectorBase): """Meshy AI 3D generation connector. Provides access to text-to-3D, image-to-3D, rigging, animation, and retexturing. @@ -26,8 +27,8 @@ def __init__( api_key: str | None = None, base_url: str | None = None, timeout: float = 300.0, - **kwargs, - ): + **kwargs: Any, + ) -> None: super().__init__(api_key=api_key, base_url=base_url, timeout=timeout, **kwargs) def text3d_generate( @@ -38,7 +39,7 @@ def text3d_generate( target_polycount: int = 30000, enable_pbr: bool = True, wait: bool = True, - ) -> Any: + ) -> ExtendedDict | ExtendedString: """Generate a 3D model from text description.""" return text3d.generate( prompt, @@ -56,7 +57,7 @@ def image3d_generate( target_polycount: int = 15000, enable_pbr: bool = True, wait: bool = True, - ) -> Any: + ) -> ExtendedDict | ExtendedString: """Generate a 3D model from an image.""" return image3d.generate( image_url, @@ -66,11 +67,11 @@ def image3d_generate( wait=wait, ) - def rig_model(self, model_id: str, wait: bool = True) -> Any: + def rig_model(self, model_id: str, wait: bool = True) -> ExtendedDict | ExtendedString: """Add skeleton/rig to a static 3D model.""" return rigging.rig(model_id, wait=wait) - def apply_animation(self, model_id: str, animation_id: int, wait: bool = True) -> Any: + def apply_animation(self, model_id: str, animation_id: int, wait: bool = True) -> ExtendedDict | ExtendedString: """Apply animation to a rigged model.""" return animate.apply(model_id, animation_id, wait=wait) @@ -80,7 +81,7 @@ def retexture_model( texture_prompt: str, enable_pbr: bool = True, wait: bool = True, - ) -> Any: + ) -> ExtendedDict | ExtendedString: """Apply new textures to an existing model.""" return retexture.apply( model_id, diff --git a/src/extended_data/connectors/meshy/image3d.py b/src/extended_data/connectors/meshy/image3d.py index 5cda9f3..a24f384 100644 --- a/src/extended_data/connectors/meshy/image3d.py +++ b/src/extended_data/connectors/meshy/image3d.py @@ -4,7 +4,7 @@ from extended_data.connectors.meshy import image3d result = image3d.generate("https://example.com/image.png") - print(result.model_urls.glb) + print(result["model_urls"]["glb"]) """ from __future__ import annotations @@ -13,9 +13,10 @@ from extended_data.connectors.meshy import base from extended_data.connectors.meshy.models import Image3DRequest, Image3DResult, TaskStatus +from extended_data.containers import ExtendedDict, ExtendedString -def create(request: Image3DRequest) -> str: +def create(request: Image3DRequest) -> ExtendedString: """Create image-to-3d task. Returns task_id.""" response = base.request( "POST", @@ -23,19 +24,16 @@ def create(request: Image3DRequest) -> str: version="v2", json=request.model_dump(exclude_none=True), ) - data = response.json() - if "result" not in data: - raise RuntimeError(f"Unexpected API response: missing 'result' key. Response: {data}") - return data["result"] + return base.task_id_from_response(response) -def get(task_id: str) -> Image3DResult: +def get(task_id: str) -> ExtendedDict: """Get task status.""" response = base.request("GET", f"image-to-3d/{task_id}", version="v2") - return Image3DResult(**response.json()) + return base.task_payload_from_response(response, Image3DResult, "image-to-3d") -def refine(task_id: str) -> str: +def refine(task_id: str) -> ExtendedString: """Refine preview to full quality. Returns new task_id.""" response = base.request( "POST", @@ -43,13 +41,10 @@ def refine(task_id: str) -> str: version="v2", json={}, ) - data = response.json() - if "result" not in data: - raise RuntimeError(f"Unexpected API response: missing 'result' key. Response: {data}") - return data["result"] + return base.task_id_from_response(response) -def poll(task_id: str, interval: float = 5.0, timeout: float = 600.0) -> Image3DResult: +def poll(task_id: str, interval: float = 5.0, timeout: float = 600.0) -> ExtendedDict: """Polls the status of an image-to-3D task until it completes, fails, expires, or times out. Args: @@ -58,7 +53,7 @@ def poll(task_id: str, interval: float = 5.0, timeout: float = 600.0) -> Image3D timeout: Maximum time in seconds to wait for task completion (default: 600.0). Returns: - Image3DResult: The result of the completed task. + Extended payload for the completed task. Raises: RuntimeError: If the task fails or expires. @@ -67,12 +62,13 @@ def poll(task_id: str, interval: float = 5.0, timeout: float = 600.0) -> Image3D start = time.time() while True: result = get(task_id) - if result.status == TaskStatus.SUCCEEDED: + status = result.get("status") + if status == TaskStatus.SUCCEEDED: return result - if result.status == TaskStatus.FAILED: - msg = f"Task failed: {result.error or 'Unknown error'}" - raise RuntimeError(msg) - if result.status == TaskStatus.EXPIRED: + if status == TaskStatus.FAILED: + error = result.get("task_error") or result.get("error") + raise RuntimeError(base.task_failure_message(error)) + if status == TaskStatus.EXPIRED: msg = "Task expired" raise RuntimeError(msg) if time.time() - start > timeout: @@ -88,7 +84,7 @@ def generate( target_polycount: int | None = None, enable_pbr: bool = True, wait: bool = True, -) -> Image3DResult | str: +) -> ExtendedDict | ExtendedString: """Generate a 3D model from an image. Args: @@ -99,7 +95,7 @@ def generate( wait: Wait for completion (default True) Returns: - Image3DResult if wait=True, task_id if wait=False + Extended result payload if wait=True, extended task_id if wait=False. """ request = Image3DRequest( mode="preview", @@ -114,4 +110,4 @@ def generate( if not wait: return task_id - return poll(task_id) + return poll(str(task_id)) diff --git a/src/extended_data/connectors/meshy/jobs.py b/src/extended_data/connectors/meshy/jobs.py index ea91963..8273b5d 100644 --- a/src/extended_data/connectors/meshy/jobs.py +++ b/src/extended_data/connectors/meshy/jobs.py @@ -7,7 +7,6 @@ from __future__ import annotations import hashlib -import json from dataclasses import asdict, dataclass from pathlib import Path @@ -15,6 +14,8 @@ from extended_data.connectors.meshy import base, text3d from extended_data.connectors.meshy.models import ArtStyle, AssetIntent, AssetSpec, Text3DRequest +from extended_data.containers import ExtendedDict, ExtendedList, extend_data +from extended_data.io import wrap_raw_data_for_export @dataclass @@ -31,14 +32,15 @@ class AssetManifest: task_id: str = "" polycount_target: int | None = None polycount_estimate: int | None = None - metadata: dict[str, Any] = None + metadata: dict[str, Any] | None = None - def __post_init__(self): + def __post_init__(self) -> None: if self.metadata is None: self.metadata = {} - def to_dict(self) -> dict[str, Any]: - return asdict(self) + def to_dict(self) -> ExtendedDict: + """Return an extended manifest payload.""" + return extend_data(asdict(self)) class AssetGenerator: @@ -58,8 +60,8 @@ def _generate_asset_id(self, spec: AssetSpec) -> str: desc_hash = hashlib.sha256(spec.description.encode()).hexdigest()[:8] return f"{spec.intent.value}_{desc_hash}" - def generate_model(self, spec: AssetSpec, wait: bool = True, poll_interval: float = 5.0) -> AssetManifest: - """Generate 3D model from spec.""" + def generate_model(self, spec: AssetSpec, wait: bool = True, poll_interval: float = 5.0) -> ExtendedDict: + """Generate 3D model from spec and return an extended manifest payload.""" asset_id = self._generate_asset_id(spec) # Create task using text3d module @@ -79,52 +81,56 @@ def generate_model(self, spec: AssetSpec, wait: bool = True, poll_interval: floa intent=spec.intent.value, description=spec.description, art_style=spec.art_style.value, - task_id=task_id, + task_id=str(task_id), polycount_target=spec.target_polycount, metadata=spec.metadata.copy() if spec.metadata else {}, ) if not wait: - return manifest + return manifest.to_dict() # Poll until complete - result = text3d.poll(task_id, interval=poll_interval) + result = text3d.poll(str(task_id), interval=poll_interval) # Download assets output_dir = self.output_root / spec.output_path output_dir.mkdir(parents=True, exist_ok=True) - if result.model_urls and result.model_urls.glb: + model_urls = result.get("model_urls") or {} + glb_url = model_urls.get("glb") + if glb_url: glb_path = output_dir / f"{asset_id}.glb" - base.download(result.model_urls.glb, str(glb_path)) + base.download(str(glb_url), str(glb_path)) manifest.model_path = str(glb_path.relative_to(self.output_root)) - if result.texture_urls and len(result.texture_urls) > 0: - textures = result.texture_urls[0] + texture_urls = result.get("texture_urls") or [] + if texture_urls and len(texture_urls) > 0: + textures = texture_urls[0] texture_paths = {} - for map_type, url in textures.model_dump(exclude_none=True).items(): + for map_type, url in textures.items(): if url: tex_path = output_dir / f"{asset_id}_{map_type}.png" - base.download(url, str(tex_path)) + base.download(str(url), str(tex_path)) texture_paths[map_type] = str(tex_path.relative_to(self.output_root)) manifest.texture_paths = texture_paths - if result.thumbnail_url: + thumbnail_url = result.get("thumbnail_url") + if thumbnail_url: thumb_path = output_dir / f"{asset_id}_thumb.png" - base.download(result.thumbnail_url, str(thumb_path)) + base.download(str(thumbnail_url), str(thumb_path)) manifest.thumbnail_path = str(thumb_path.relative_to(self.output_root)) # Save manifest manifest_path = output_dir / f"{asset_id}_manifest.json" with open(manifest_path, "w") as f: - json.dump(manifest.to_dict(), f, indent=2) + f.write(wrap_raw_data_for_export(manifest.to_dict(), allow_encoding="json", indent_2=True)) - return manifest + return manifest.to_dict() - def batch_generate(self, specs: list[AssetSpec], max_concurrent: int = 3) -> list[AssetManifest]: - """Generate multiple assets (respecting rate limits).""" + def batch_generate(self, specs: list[AssetSpec], max_concurrent: int = 3) -> ExtendedList[ExtendedDict]: + """Generate multiple assets and return extended manifest payloads.""" manifests = [] for spec in specs: @@ -134,7 +140,7 @@ def batch_generate(self, specs: list[AssetSpec], max_concurrent: int = 3) -> lis except Exception: # noqa: S112 - batch continues on individual failures continue - return manifests + return extend_data(manifests) # Example specs diff --git a/src/extended_data/connectors/meshy/logging.py b/src/extended_data/connectors/meshy/logging.py index a603d4d..fc6b434 100644 --- a/src/extended_data/connectors/meshy/logging.py +++ b/src/extended_data/connectors/meshy/logging.py @@ -1,33 +1,47 @@ -"""Rich logging configuration for Meshy SDK.""" +"""Meshy logging helpers backed by Extended Data lifecycle logging.""" from __future__ import annotations -import logging +from collections.abc import Sequence -from rich.logging import RichHandler +from extended_data.logging import Logging +from extended_data.logging.const import VERBOSITY +from extended_data.logging.utils import get_log_level -def setup_logging(level: str = "INFO") -> logging.Logger: - """Configure Rich logging with proper exception handling. +MESHY_LOGGER_NAME = "extended_data.connectors.meshy" +MESHY_STORAGE_MARKER = "meshy" - Args: - level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) - Returns: - Configured logger instance +def create_meshy_logger( + *, + level: int | str = "INFO", + logger_name: str = MESHY_LOGGER_NAME, + enable_console: bool = False, + enable_file: bool = False, + log_file_name: str | None = None, + default_storage_marker: str | None = MESHY_STORAGE_MARKER, + allowed_levels: Sequence[str] | None = None, + denied_levels: Sequence[str] | None = None, + enable_verbose_output: bool = False, + verbosity_threshold: int = VERBOSITY, +) -> Logging: + """Create an Extended Data logger configured for Meshy workflows. + + The helper intentionally avoids import-time side effects and global + ``logging.basicConfig`` changes. Callers opt into console or file output + the same way they do with the package-level ``Logging`` surface. """ - logging.basicConfig( - level=level, - format="%(message)s", - datefmt="[%X]", - handlers=[RichHandler(rich_tracebacks=True, markup=False, show_path=True)], + logger = Logging( + enable_console=enable_console, + enable_file=enable_file, + logger_name=logger_name, + log_file_name=log_file_name, + default_storage_marker=default_storage_marker, + allowed_levels=allowed_levels, + denied_levels=denied_levels, + enable_verbose_output=enable_verbose_output, + verbosity_threshold=verbosity_threshold, ) - - logger = logging.getLogger("meshy") - logger.setLevel(level) - + logger.logger.setLevel(get_log_level(level)) return logger - - -# Global logger instance -logger = setup_logging() diff --git a/src/extended_data/connectors/meshy/mcp.py b/src/extended_data/connectors/meshy/mcp.py index 2c8e5b2..6129547 100644 --- a/src/extended_data/connectors/meshy/mcp.py +++ b/src/extended_data/connectors/meshy/mcp.py @@ -29,15 +29,18 @@ from __future__ import annotations -import json +from collections.abc import Callable, Iterable, Mapping +from typing import Any, cast -from typing import Any +from extended_data.containers import to_builtin +from extended_data.io import wrap_raw_data_for_export +from extended_data.primitives.redaction import redact_sensitive_data, redact_sensitive_text MCP_INSTALL_MESSAGE = "MCP SDK not installed. Install with: pip install extended-data[meshy,mcp]" -def _create_mcp_tools() -> list[Any]: +def _create_mcp_tools() -> list[tuple[Any, Callable[..., Any]]]: """Create MCP tool definitions from Meshy functions. Returns: @@ -52,7 +55,7 @@ def _create_mcp_tools() -> list[Any]: from extended_data.connectors.meshy import tools # Define tool schemas manually for better control - tool_schemas = [ + tool_schemas: list[dict[str, Any]] = [ { "name": "text3d_generate", "description": ( @@ -219,8 +222,8 @@ def _create_mcp_tools() -> list[Any]: mcp_tools = [] for schema in tool_schemas: # Build JSON schema properties and required list - properties = {} - required = [] + properties: dict[str, Any] = {} + required: list[str] = [] for param_name, param_def in schema["parameters"].items(): prop = { @@ -253,7 +256,34 @@ def _create_mcp_tools() -> list[Any]: return mcp_tools -def create_server(): +def _jsonable_tool_result(result: Any) -> Any: + """Lower Meshy tool results to JSON-compatible redacted data.""" + if hasattr(result, "model_dump"): + result = result.model_dump() + elif isinstance(result, Iterable) and not isinstance(result, (str, bytes, bytearray, Mapping)): + result = [item.model_dump() if hasattr(item, "model_dump") else item for item in result] + result = to_builtin(result) + if isinstance(result, set | frozenset): + result = [to_builtin(item) for item in result] + return redact_sensitive_data(result) + + +def _tool_error_payload(error: object, *, values: Iterable[Any] | None = None) -> dict[str, str]: + """Return an MCP-safe error payload without raw secret values.""" + return {"error": redact_sensitive_text(error, values=values)} + + +def _tool_payload_text(payload: Any) -> str: + """Return a serialized MCP text payload through the shared export boundary.""" + return wrap_raw_data_for_export(payload, allow_encoding="json", indent_2=True) + + +def _tool_result_text(result: Any) -> str: + """Return a serialized Meshy MCP result through the shared export boundary.""" + return _tool_payload_text(_jsonable_tool_result(result)) + + +def create_server() -> Any: """Create an MCP server with Meshy AI tools. Returns: @@ -274,40 +304,44 @@ def create_server(): tool_handlers = {tool.name: func for tool, func in mcp_tools} tool_list = [tool for tool, _ in mcp_tools] + tool_decorator = cast(Callable[[], Callable[[Callable[..., Any]], Callable[..., Any]]], server.list_tools) + call_decorator = cast(Callable[[], Callable[[Callable[..., Any]], Callable[..., Any]]], server.call_tool) + # Register tools - @server.list_tools() - async def list_tools(): + @tool_decorator() + async def list_tools() -> list[Any]: return tool_list # Handle tool calls - @server.call_tool() - async def call_tool(name: str, arguments: dict[str, Any]) -> list[Any]: + @call_decorator() + async def call_tool(name: str, arguments: dict[str, Any] | None) -> list[Any]: from mcp.types import TextContent + tool_arguments = arguments or {} handler = tool_handlers.get(name) if not handler: return [ TextContent( type="text", - text=json.dumps({"error": f"Unknown tool: {name}"}), + text=_tool_payload_text(_tool_error_payload(f"Unknown tool: {name}")), ) ] try: - result = handler(**arguments) - return [TextContent(type="text", text=json.dumps(result, indent=2))] + result = handler(**tool_arguments) + return [TextContent(type="text", text=_tool_result_text(result))] except Exception as e: return [ TextContent( type="text", - text=json.dumps({"error": str(e)}, indent=2), + text=_tool_payload_text(_tool_error_payload(e, values=tool_arguments.values())), ) ] return server -def run_server(server=None): +def run_server(server: Any | None = None) -> None: """Run the MCP server. Args: @@ -323,7 +357,7 @@ def run_server(server=None): if server is None: server = create_server() - async def main(): + async def main() -> None: async with stdio_server() as (read_stream, write_stream): await server.run( read_stream, @@ -334,7 +368,7 @@ async def main(): asyncio.run(main()) -def main(): +def main() -> None: """Entry point for the MCP server.""" run_server() diff --git a/src/extended_data/connectors/meshy/persistence/repository.py b/src/extended_data/connectors/meshy/persistence/repository.py index 0400a1e..05113c6 100644 --- a/src/extended_data/connectors/meshy/persistence/repository.py +++ b/src/extended_data/connectors/meshy/persistence/repository.py @@ -2,13 +2,12 @@ from __future__ import annotations -import json import os import tempfile from datetime import datetime, timezone from pathlib import Path -from typing import Any +from typing import Any, cast from extended_data.connectors.meshy.persistence.schemas import ( ArtifactRecord, @@ -19,6 +18,10 @@ TaskSubmission, ) from extended_data.connectors.meshy.persistence.utils import compute_spec_hash as util_compute_spec_hash +from extended_data.containers import ExtendedDict, ExtendedList, extend_data +from extended_data.io import wrap_raw_data_for_export +from extended_data.io.files import DataFile +from extended_data.primitives.redaction import redact_sensitive_text def _utc_now() -> datetime: @@ -26,6 +29,16 @@ def _utc_now() -> datetime: return datetime.now(timezone.utc) +def _manifest_payload(manifest: ProjectManifest) -> dict[str, Any]: + """Convert an internal project manifest model to a JSON-friendly payload.""" + return manifest.model_dump(mode="json") + + +def _asset_payload(asset: AssetManifest) -> dict[str, Any]: + """Convert an internal asset manifest model to a JSON-friendly payload.""" + return asset.model_dump(mode="json") + + class TaskRepository: """File-backed repository for task manifests with atomic operations.""" @@ -37,15 +50,8 @@ def _manifest_path(self, project: str) -> Path: """Get path to project manifest file.""" return self.base_path / project / "manifest.json" - def load_project_manifest(self, project: str) -> ProjectManifest: - """Load manifest for a project, creating empty one if missing. - - Args: - project: Project name (e.g., "otter", "beaver") - - Returns: - ProjectManifest instance - """ + def _load_project_manifest_model(self, project: str) -> ProjectManifest: + """Load the internal project manifest model, creating an empty one if missing.""" manifest_path = self._manifest_path(project) if not manifest_path.exists(): @@ -54,9 +60,19 @@ def load_project_manifest(self, project: str) -> ProjectManifest: self.save_project_manifest(manifest) return manifest - with open(manifest_path) as f: - data = json.load(f) - return ProjectManifest(**data) + data = DataFile.read(manifest_path, as_extended=False).as_builtin() + return ProjectManifest(**data) + + def load_project_manifest(self, project: str) -> ExtendedDict: + """Load manifest for a project, creating empty one if missing. + + Args: + project: Project name (e.g., "otter", "beaver") + + Returns: + Extended project manifest payload. + """ + return cast(ExtendedDict, extend_data(_manifest_payload(self._load_project_manifest_model(project)))) def save_project_manifest(self, manifest: ProjectManifest) -> None: """Atomically save project manifest to disk. @@ -73,13 +89,13 @@ def save_project_manifest(self, manifest: ProjectManifest) -> None: # Atomic write: write to temp file, then rename with tempfile.NamedTemporaryFile(mode="w", dir=manifest_path.parent, delete=False, suffix=".tmp") as tmp_file: - json.dump(manifest_dict, tmp_file, indent=2) + tmp_file.write(wrap_raw_data_for_export(manifest_dict, allow_encoding="json", indent_2=True)) tmp_path = tmp_file.name # Atomic rename os.replace(tmp_path, manifest_path) - def get_asset_record(self, project: str, spec_hash: str) -> AssetManifest | None: + def get_asset_record(self, project: str, spec_hash: str) -> ExtendedDict | None: """Get asset manifest by spec hash. Args: @@ -87,10 +103,13 @@ def get_asset_record(self, project: str, spec_hash: str) -> AssetManifest | None spec_hash: Asset spec hash Returns: - AssetManifest if found, None otherwise + Extended asset manifest payload if found, None otherwise """ - manifest = self.load_project_manifest(project) - return manifest.asset_specs.get(spec_hash) + manifest = self._load_project_manifest_model(project) + asset = manifest.asset_specs.get(spec_hash) + if asset is None: + return None + return cast(ExtendedDict, extend_data(_asset_payload(asset))) def upsert_asset_record(self, project: str, asset_manifest: AssetManifest) -> None: """Insert or update asset manifest. @@ -99,7 +118,7 @@ def upsert_asset_record(self, project: str, asset_manifest: AssetManifest) -> No project: Project name asset_manifest: AssetManifest to save """ - manifest = self.load_project_manifest(project) + manifest = self._load_project_manifest_model(project) asset_manifest.updated_at = _utc_now() manifest.asset_specs[asset_manifest.asset_spec_hash] = asset_manifest self.save_project_manifest(manifest) @@ -131,13 +150,15 @@ def record_task_update( source: Update source (orchestrator, webhook, manual) error: Error message if failed """ - manifest = self.load_project_manifest(project) + manifest = self._load_project_manifest_model(project) asset_record = manifest.asset_specs.get(spec_hash) if not asset_record: msg = f"Asset {spec_hash} not found for project {project}" raise ValueError(msg) + safe_error = redact_sensitive_text(error) if error else None + # Find existing task entry or create new task_entry = None for entry in asset_record.task_graph: @@ -154,8 +175,8 @@ def record_task_update( if result_paths: task_entry.result_paths.update(result_paths) - if error: - task_entry.error = error + if safe_error: + task_entry.error = safe_error # Record status transition asset_record.history.append( @@ -178,7 +199,7 @@ def record_task_update( updated_at=_utc_now(), payload=payload or {}, result_paths=result_paths or {}, - error=error, + error=safe_error, ) asset_record.task_graph.append(task_entry) @@ -200,28 +221,28 @@ def record_task_update( # Save updated manifest self.save_project_manifest(manifest) - def list_pending_assets(self, project: str) -> list[AssetManifest]: + def list_pending_assets(self, project: str) -> ExtendedList[ExtendedDict]: """List all assets with pending/in-progress tasks. Args: project: Project name Returns: - List of AssetManifest with non-terminal tasks + Extended asset manifest payloads with non-terminal tasks """ - manifest = self.load_project_manifest(project) - pending = [] + manifest = self._load_project_manifest_model(project) + pending: list[dict[str, Any]] = [] terminal_statuses = {"SUCCEEDED", "FAILED", "EXPIRED", "CANCELED"} for asset_record in manifest.asset_specs.values(): has_pending = any(task.status not in terminal_statuses for task in asset_record.task_graph) if has_pending: - pending.append(asset_record) + pending.append(_asset_payload(asset_record)) - return pending + return cast(ExtendedList[ExtendedDict], extend_data(pending)) - def find_task_by_id(self, task_id: str, project: str | None = None) -> tuple[str, str, AssetManifest] | None: + def find_task_by_id(self, task_id: str, project: str | None = None) -> ExtendedDict | None: """Find asset by task ID (for webhook lookups). Args: @@ -229,7 +250,7 @@ def find_task_by_id(self, task_id: str, project: str | None = None) -> tuple[str project: Optional project to narrow search Returns: - Tuple of (project, spec_hash, AssetManifest) if found + Extended payload with project, spec_hash, and asset if found """ # Determine which project to search if project: @@ -239,11 +260,18 @@ def find_task_by_id(self, task_id: str, project: str | None = None) -> tuple[str project_list = [d.name for d in self.base_path.iterdir() if d.is_dir() and (d / "manifest.json").exists()] for sp in project_list: - manifest = self.load_project_manifest(sp) + manifest = self._load_project_manifest_model(sp) for spec_hash, asset_record in manifest.asset_specs.items(): for task in asset_record.task_graph: if task.task_id == task_id: - return (sp, spec_hash, asset_record) + return cast( + ExtendedDict, + extend_data({ + "project": sp, + "spec_hash": spec_hash, + "asset": _asset_payload(asset_record), + }), + ) return None @@ -280,7 +308,7 @@ def record_task_submission(self, submission: TaskSubmission) -> None: msg = "spec_hash cannot be empty" raise ValueError(msg) - manifest = self.load_project_manifest(submission.project) + manifest = self._load_project_manifest_model(submission.project) asset_record = manifest.asset_specs.get(submission.spec_hash) if not asset_record: diff --git a/src/extended_data/connectors/meshy/persistence/vector_store.py b/src/extended_data/connectors/meshy/persistence/vector_store.py index 3410be6..b43e7c8 100644 --- a/src/extended_data/connectors/meshy/persistence/vector_store.py +++ b/src/extended_data/connectors/meshy/persistence/vector_store.py @@ -27,11 +27,13 @@ existing = store.get_by_spec_hash("abc123") Requirements: - pip install mesh-toolkit[vector] + pip install "extended-data[meshy,vector]" The vector extra includes: - sqlite-vec (vector similarity extension) - - Optional: sentence-transformers for embeddings + + get_embedding() uses sentence-transformers only when users install it + independently after reviewing its torch dependency tree. """ from __future__ import annotations @@ -41,14 +43,22 @@ import sqlite3 from contextlib import contextmanager, suppress -from dataclasses import dataclass, field +from dataclasses import asdict, dataclass, field from datetime import datetime, timezone from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast + +from typing_extensions import Self + +from extended_data.containers import ExtendedDict, ExtendedList, extend_data +from extended_data.io import wrap_raw_data_for_export +from extended_data.io.files import decode_file +from extended_data.primitives.formats.errors import DataDecodeError if TYPE_CHECKING: from collections.abc import Iterator + from types import TracebackType # Vector extension is optional _HAS_VECTOR = False @@ -91,6 +101,23 @@ class SimilarityResult: score: float # 1 - distance (higher = more similar) +def _record_payload(record: GenerationRecord) -> dict[str, Any]: + """Convert an internal generation record to a JSON-friendly payload.""" + payload = asdict(record) + payload["created_at"] = record.created_at.isoformat() + payload["updated_at"] = record.updated_at.isoformat() + return payload + + +def _similarity_payload(result: SimilarityResult) -> dict[str, Any]: + """Convert an internal similarity result to a JSON-friendly payload.""" + return { + "record": _record_payload(result.record), + "distance": result.distance, + "score": result.score, + } + + class VectorStore: """SQLite vector store for asset generation tracking and RAG. @@ -217,7 +244,7 @@ def record_generation( task_id: str | None = None, embedding: list[float] | None = None, metadata: dict[str, Any] | None = None, - ) -> GenerationRecord: + ) -> ExtendedDict: """Record a new generation (idempotent by spec_hash). If a record with the same spec_hash exists, returns existing. @@ -232,7 +259,7 @@ def record_generation( metadata: Additional metadata dict Returns: - GenerationRecord (existing or newly created) + Extended generation record payload (existing or newly created) """ now = _utc_now().isoformat() @@ -242,10 +269,10 @@ def record_generation( row = cursor.fetchone() if row: - return self._row_to_record(row) + return cast(ExtendedDict, extend_data(_record_payload(self._row_to_record(row)))) # Insert new record - metadata_json = json.dumps(metadata) if metadata else None + metadata_json = wrap_raw_data_for_export(metadata, allow_encoding="json") if metadata else None cursor = conn.execute( """ @@ -267,7 +294,7 @@ def record_generation( (record_id, embedding_blob), ) - return GenerationRecord( + record = GenerationRecord( id=record_id, spec_hash=spec_hash, project=project, @@ -280,6 +307,7 @@ def record_generation( created_at=datetime.fromisoformat(now), updated_at=datetime.fromisoformat(now), ) + return cast(ExtendedDict, extend_data(_record_payload(record))) def update_status( self, @@ -322,40 +350,44 @@ def update_status( return cursor.rowcount > 0 - def get_by_spec_hash(self, spec_hash: str) -> GenerationRecord | None: + def get_by_spec_hash(self, spec_hash: str) -> ExtendedDict | None: """Get generation record by spec hash. Args: spec_hash: Generation spec hash Returns: - GenerationRecord or None + Extended generation record payload or None """ conn = self._get_conn() cursor = conn.execute("SELECT * FROM generations WHERE spec_hash = ?", (spec_hash,)) row = cursor.fetchone() - return self._row_to_record(row) if row else None + if not row: + return None + return cast(ExtendedDict, extend_data(_record_payload(self._row_to_record(row)))) - def get_by_task_id(self, task_id: str) -> GenerationRecord | None: + def get_by_task_id(self, task_id: str) -> ExtendedDict | None: """Get generation record by Meshy task ID. Args: task_id: Meshy task ID Returns: - GenerationRecord or None + Extended generation record payload or None """ conn = self._get_conn() cursor = conn.execute("SELECT * FROM generations WHERE task_id = ?", (task_id,)) row = cursor.fetchone() - return self._row_to_record(row) if row else None + if not row: + return None + return cast(ExtendedDict, extend_data(_record_payload(self._row_to_record(row)))) def search_similar( self, query_embedding: list[float], limit: int = 10, project: str | None = None, - ) -> list[SimilarityResult]: + ) -> ExtendedList[ExtendedDict]: """Search for similar generations using vector similarity. Args: @@ -364,10 +396,10 @@ def search_similar( project: Optional project filter Returns: - List of SimilarityResult ordered by similarity (highest first) + Extended similarity result payloads ordered by similarity (highest first) """ if not _HAS_VECTOR: - return [] + return cast(ExtendedList[ExtendedDict], extend_data([])) conn = self._get_conn() query_blob = self._serialize_embedding(query_embedding) @@ -396,26 +428,28 @@ def search_similar( (query_blob, limit), ) - results = [] + results: list[dict[str, Any]] = [] for row in cursor: record = self._row_to_record(row) distance = row["distance"] results.append( - SimilarityResult( - record=record, - distance=distance, - score=1.0 - min(distance, 1.0), + _similarity_payload( + SimilarityResult( + record=record, + distance=distance, + score=1.0 - min(distance, 1.0), + ) ) ) - return results + return cast(ExtendedList[ExtendedDict], extend_data(results)) def search_text( self, query: str, limit: int = 10, project: str | None = None, - ) -> list[GenerationRecord]: + ) -> ExtendedList[ExtendedDict]: """Full-text search for prompts. Falls back to this when vector search is unavailable. @@ -426,7 +460,7 @@ def search_text( project: Optional project filter Returns: - List of matching GenerationRecords + Extended generation record payloads """ conn = self._get_conn() @@ -453,16 +487,19 @@ def search_text( (query, limit), ) - return [self._row_to_record(row) for row in cursor] + return cast( + ExtendedList[ExtendedDict], + extend_data([_record_payload(self._row_to_record(row)) for row in cursor]), + ) - def list_pending(self, project: str | None = None) -> list[GenerationRecord]: + def list_pending(self, project: str | None = None) -> ExtendedList[ExtendedDict]: """List all pending/in-progress generations. Args: project: Optional project filter Returns: - List of pending GenerationRecords + Extended pending generation record payloads """ conn = self._get_conn() @@ -474,7 +511,10 @@ def list_pending(self, project: str | None = None) -> list[GenerationRecord]: else: cursor = conn.execute("SELECT * FROM generations WHERE status IN ('pending', 'in_progress')") - return [self._row_to_record(row) for row in cursor] + return cast( + ExtendedList[ExtendedDict], + extend_data([_record_payload(self._row_to_record(row)) for row in cursor]), + ) def compute_spec_hash(self, spec: dict[str, Any]) -> str: """Compute deterministic hash for a generation spec. @@ -493,8 +533,8 @@ def _row_to_record(self, row: sqlite3.Row) -> GenerationRecord: """Convert database row to GenerationRecord.""" metadata = {} if row["metadata_json"]: - with suppress(json.JSONDecodeError): - metadata = json.loads(row["metadata_json"]) + with suppress(DataDecodeError): + metadata = decode_file(row["metadata_json"], suffix="json", as_extended=False) return GenerationRecord( id=row["id"], @@ -522,15 +562,20 @@ def close(self) -> None: self._conn.close() self._conn = None - def __enter__(self): + def __enter__(self) -> Self: return self - def __exit__(self, *args): + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: self.close() # Convenience function for getting embeddings -def get_embedding(text: str, model: str = "all-MiniLM-L6-v2") -> list[float] | None: +def get_embedding(text: str, model: str = "all-MiniLM-L6-v2") -> ExtendedList[float] | None: """Get embedding for text using sentence-transformers. Args: @@ -538,13 +583,13 @@ def get_embedding(text: str, model: str = "all-MiniLM-L6-v2") -> list[float] | N model: Model name (default: all-MiniLM-L6-v2) Returns: - Embedding vector or None if sentence-transformers not available + Extended embedding vector or None if sentence-transformers not available """ try: from sentence_transformers import SentenceTransformer encoder = SentenceTransformer(model) embedding = encoder.encode(text) - return embedding.tolist() + return cast(ExtendedList[float], extend_data(embedding.tolist())) except ImportError: return None diff --git a/src/extended_data/connectors/meshy/retexture.py b/src/extended_data/connectors/meshy/retexture.py index 422d63f..e5685b0 100644 --- a/src/extended_data/connectors/meshy/retexture.py +++ b/src/extended_data/connectors/meshy/retexture.py @@ -12,9 +12,10 @@ from extended_data.connectors.meshy import base from extended_data.connectors.meshy.models import RetextureRequest, RetextureResult, TaskStatus +from extended_data.containers import ExtendedDict, ExtendedString -def create(request: RetextureRequest) -> str: +def create(request: RetextureRequest) -> ExtendedString: """Create retexture task. Returns task_id.""" response = base.request( "POST", @@ -22,28 +23,27 @@ def create(request: RetextureRequest) -> str: version="v1", json=request.model_dump(exclude_none=True), ) - return response.json().get("result") + return base.task_id_from_response(response) -def get(task_id: str) -> RetextureResult: +def get(task_id: str) -> ExtendedDict: """Get task status.""" response = base.request("GET", f"retexture/{task_id}", version="v1") - return RetextureResult(**response.json()) + return base.task_payload_from_response(response, RetextureResult, "retexture") -def poll(task_id: str, interval: float = 5.0, timeout: float = 600.0) -> RetextureResult: +def poll(task_id: str, interval: float = 5.0, timeout: float = 600.0) -> ExtendedDict: """Poll until complete or failed.""" start = time.time() while True: result = get(task_id) - if result.status == TaskStatus.SUCCEEDED: + status = result.get("status") + if status == TaskStatus.SUCCEEDED: return result - if result.status == TaskStatus.FAILED: - error = getattr(result, "task_error", {}) - msg = error.get("message", "Unknown error") if isinstance(error, dict) else str(error) - msg = f"Task failed: {msg}" - raise RuntimeError(msg) - if result.status == TaskStatus.EXPIRED: + if status == TaskStatus.FAILED: + error = result.get("task_error") or result.get("error") + raise RuntimeError(base.task_failure_message(error)) + if status == TaskStatus.EXPIRED: msg = "Task expired" raise RuntimeError(msg) if time.time() - start > timeout: @@ -59,7 +59,7 @@ def apply( enable_original_uv: bool = True, enable_pbr: bool = True, wait: bool = True, -) -> RetextureResult | str: +) -> ExtendedDict | ExtendedString: """Apply new textures to a model. Args: @@ -70,7 +70,7 @@ def apply( wait: Wait for completion (default True) Returns: - RetextureResult if wait=True, task_id if wait=False + Extended result payload if wait=True, extended task_id if wait=False. """ request = RetextureRequest( input_task_id=model_task_id, @@ -84,7 +84,7 @@ def apply( if not wait: return task_id - return poll(task_id) + return poll(str(task_id)) def apply_from_image( @@ -94,7 +94,7 @@ def apply_from_image( enable_original_uv: bool = True, enable_pbr: bool = True, wait: bool = True, -) -> RetextureResult | str: +) -> ExtendedDict | ExtendedString: """Apply textures based on reference image. Args: @@ -105,7 +105,7 @@ def apply_from_image( wait: Wait for completion (default True) Returns: - RetextureResult if wait=True, task_id if wait=False + Extended result payload if wait=True, extended task_id if wait=False. """ request = RetextureRequest( input_task_id=model_task_id, @@ -119,4 +119,4 @@ def apply_from_image( if not wait: return task_id - return poll(task_id) + return poll(str(task_id)) diff --git a/src/extended_data/connectors/meshy/rigging.py b/src/extended_data/connectors/meshy/rigging.py index bc3195a..cfa7631 100644 --- a/src/extended_data/connectors/meshy/rigging.py +++ b/src/extended_data/connectors/meshy/rigging.py @@ -12,9 +12,10 @@ from extended_data.connectors.meshy import base from extended_data.connectors.meshy.models import RiggingRequest, RiggingResult, TaskStatus +from extended_data.containers import ExtendedDict, ExtendedString -def create(request: RiggingRequest) -> str: +def create(request: RiggingRequest) -> ExtendedString: """Create rigging task. Returns task_id.""" response = base.request( "POST", @@ -22,28 +23,27 @@ def create(request: RiggingRequest) -> str: version="v1", json=request.model_dump(exclude_none=True), ) - return response.json().get("result") + return base.task_id_from_response(response) -def get(task_id: str) -> RiggingResult: +def get(task_id: str) -> ExtendedDict: """Get task status.""" response = base.request("GET", f"rigging/{task_id}", version="v1") - return RiggingResult(**response.json()) + return base.task_payload_from_response(response, RiggingResult, "rigging") -def poll(task_id: str, interval: float = 5.0, timeout: float = 600.0) -> RiggingResult: +def poll(task_id: str, interval: float = 5.0, timeout: float = 600.0) -> ExtendedDict: """Poll until complete or failed.""" start = time.time() while True: result = get(task_id) - if result.status == TaskStatus.SUCCEEDED: + status = result.get("status") + if status == TaskStatus.SUCCEEDED: return result - if result.status == TaskStatus.FAILED: - error = getattr(result, "task_error", {}) - msg = error.get("message", "Unknown error") if isinstance(error, dict) else str(error) - msg = f"Task failed: {msg}" - raise RuntimeError(msg) - if result.status == TaskStatus.EXPIRED: + if status == TaskStatus.FAILED: + error = result.get("task_error") or result.get("error") + raise RuntimeError(base.task_failure_message(error)) + if status == TaskStatus.EXPIRED: msg = "Task expired" raise RuntimeError(msg) if time.time() - start > timeout: @@ -57,7 +57,7 @@ def rig( *, height_meters: float = 1.7, wait: bool = True, -) -> RiggingResult | str: +) -> ExtendedDict | ExtendedString: """Rig a model for animation. Args: @@ -66,7 +66,7 @@ def rig( wait: Wait for completion (default True) Returns: - RiggingResult if wait=True, task_id if wait=False + Extended result payload if wait=True, extended task_id if wait=False. """ request = RiggingRequest( input_task_id=model_task_id, @@ -78,7 +78,7 @@ def rig( if not wait: return task_id - return poll(task_id) + return poll(str(task_id)) def rig_from_url( @@ -87,7 +87,7 @@ def rig_from_url( height_meters: float = 1.7, texture_url: str | None = None, wait: bool = True, -) -> RiggingResult | str: +) -> ExtendedDict | ExtendedString: """Rig a model from URL. Args: @@ -97,7 +97,7 @@ def rig_from_url( wait: Wait for completion (default True) Returns: - RiggingResult if wait=True, task_id if wait=False + Extended result payload if wait=True, extended task_id if wait=False. """ request = RiggingRequest( model_url=model_url, @@ -110,4 +110,4 @@ def rig_from_url( if not wait: return task_id - return poll(task_id) + return poll(str(task_id)) diff --git a/src/extended_data/connectors/meshy/text3d.py b/src/extended_data/connectors/meshy/text3d.py index 05fa743..865883b 100644 --- a/src/extended_data/connectors/meshy/text3d.py +++ b/src/extended_data/connectors/meshy/text3d.py @@ -4,7 +4,7 @@ from extended_data.connectors.meshy import text3d result = text3d.generate("a medieval sword") - print(result.model_urls.glb) + print(result["model_urls"]["glb"]) """ from __future__ import annotations @@ -13,9 +13,10 @@ from extended_data.connectors.meshy import base from extended_data.connectors.meshy.models import ArtStyle, TaskStatus, Text3DRequest, Text3DResult +from extended_data.containers import ExtendedDict, ExtendedString -def create(request: Text3DRequest) -> str: +def create(request: Text3DRequest) -> ExtendedString: """Create text-to-3d task. Returns task_id.""" response = base.request( "POST", @@ -23,16 +24,16 @@ def create(request: Text3DRequest) -> str: version="v2", json=request.model_dump(exclude_none=True), ) - return response.json().get("result") + return base.task_id_from_response(response) -def get(task_id: str) -> Text3DResult: +def get(task_id: str) -> ExtendedDict: """Get task status.""" response = base.request("GET", f"text-to-3d/{task_id}", version="v2") - return Text3DResult(**response.json()) + return base.task_payload_from_response(response, Text3DResult, "text-to-3d") -def refine(task_id: str) -> str: +def refine(task_id: str) -> ExtendedString: """Refine preview to full quality. Returns new task_id.""" response = base.request( "POST", @@ -40,22 +41,21 @@ def refine(task_id: str) -> str: version="v2", json={}, ) - return response.json().get("result") + return base.task_id_from_response(response) -def poll(task_id: str, interval: float = 5.0, timeout: float = 600.0) -> Text3DResult: +def poll(task_id: str, interval: float = 5.0, timeout: float = 600.0) -> ExtendedDict: """Poll until complete or failed.""" start = time.time() while True: result = get(task_id) - if result.status == TaskStatus.SUCCEEDED: + status = result.get("status") + if status == TaskStatus.SUCCEEDED: return result - if result.status == TaskStatus.FAILED: - error = getattr(result, "task_error", {}) - msg = error.get("message", "Unknown error") if isinstance(error, dict) else str(error) - msg = f"Task failed: {msg}" - raise RuntimeError(msg) - if result.status == TaskStatus.EXPIRED: + if status == TaskStatus.FAILED: + error = result.get("task_error") or result.get("error") + raise RuntimeError(base.task_failure_message(error)) + if status == TaskStatus.EXPIRED: msg = "Task expired" raise RuntimeError(msg) if time.time() - start > timeout: @@ -72,7 +72,7 @@ def generate( target_polycount: int = 15000, enable_pbr: bool = True, wait: bool = True, -) -> Text3DResult | str: +) -> ExtendedDict | ExtendedString: """Generate a 3D model from text. Args: @@ -84,7 +84,7 @@ def generate( wait: Wait for completion (default True) Returns: - Text3DResult if wait=True, task_id if wait=False + Extended result payload if wait=True, extended task_id if wait=False. """ if isinstance(art_style, str): art_style = ArtStyle(art_style) @@ -103,4 +103,4 @@ def generate( if not wait: return task_id - return poll(task_id) + return poll(str(task_id)) diff --git a/src/extended_data/connectors/meshy/tools.py b/src/extended_data/connectors/meshy/tools.py index b68c3b1..59022c3 100644 --- a/src/extended_data/connectors/meshy/tools.py +++ b/src/extended_data/connectors/meshy/tools.py @@ -7,10 +7,14 @@ from __future__ import annotations +from collections.abc import Callable, Mapping from typing import Any from pydantic import BaseModel, Field +from extended_data.connectors.ai_tools import raise_unknown_tool_framework +from extended_data.containers import ExtendedDict, extend_data + # ============================================================================= # Pydantic Schemas for Tool Inputs @@ -94,7 +98,20 @@ class GetAnimationSchema(BaseModel): # ============================================================================= -def _extract_result_fields(result: object) -> dict[str, object]: +def _result_get(result: object, field: str, default: object = None) -> object: + """Read a field from an extended payload or a model-like test double.""" + if isinstance(result, Mapping): + return result.get(field, default) + return getattr(result, field, default) + + +def _result_status(result: object) -> str: + """Read a task status from an extended payload or model-like object.""" + status = _result_get(result, "status", "unknown") + return str(status.value) if hasattr(status, "value") else str(status) + + +def _extract_result_fields(result: object) -> ExtendedDict: """Extract common fields from Meshy API result objects. Safely extracts status, model_url, and thumbnail_url from result objects, @@ -107,21 +124,24 @@ def _extract_result_fields(result: object) -> dict[str, object]: Dict with status, model_url, and thumbnail_url fields """ # Extract status - prefer .value if it's an enum, otherwise str() - status = getattr(result.status, "value", str(result.status)) if hasattr(result, "status") else "unknown" + status = _result_status(result) # Extract model_url from model_urls.glb if available model_url = None - if hasattr(result, "model_urls") and result.model_urls: - model_url = result.model_urls.glb + model_urls = _result_get(result, "model_urls") + if isinstance(model_urls, Mapping): + model_url = model_urls.get("glb") + elif model_urls: + model_url = getattr(model_urls, "glb", None) # Extract thumbnail_url - thumbnail_url = getattr(result, "thumbnail_url", None) + thumbnail_url = _result_get(result, "thumbnail_url") - return { + return extend_data({ "status": status, "model_url": model_url, "thumbnail_url": thumbnail_url, - } + }) def text3d_generate( @@ -130,7 +150,7 @@ def text3d_generate( negative_prompt: str = "", target_polycount: int = 30000, enable_pbr: bool = True, -) -> dict[str, Any]: +) -> ExtendedDict: """Generate a 3D model from text description. Args: @@ -156,11 +176,18 @@ def text3d_generate( wait=True, ) + if isinstance(result, str): + return extend_data({ + "task_id": result, + "status": "pending", + "message": "Text-to-3D task submitted", + }) + fields = _extract_result_fields(result) - return { - "task_id": result.id, + return extend_data({ + "task_id": _result_get(result, "id"), **fields, - } + }) def image3d_generate( @@ -168,7 +195,7 @@ def image3d_generate( topology: str = "", target_polycount: int = 15000, enable_pbr: bool = True, -) -> dict[str, Any]: +) -> ExtendedDict: """Generate a 3D model from an image. Args: @@ -190,14 +217,21 @@ def image3d_generate( wait=True, ) + if isinstance(result, str): + return extend_data({ + "task_id": result, + "status": "pending", + "message": "Image-to-3D task submitted", + }) + fields = _extract_result_fields(result) - return { - "task_id": result.id, + return extend_data({ + "task_id": _result_get(result, "id"), **fields, - } + }) -def rig_model(model_id: str, wait: bool = True) -> dict[str, Any]: +def rig_model(model_id: str, wait: bool = True) -> ExtendedDict: """Add skeleton/rig to a static 3D model. Args: @@ -211,21 +245,25 @@ def rig_model(model_id: str, wait: bool = True) -> dict[str, Any]: result = rigging.rig(model_id, wait=wait) + if isinstance(result, str): + return extend_data({ + "task_id": result, + "status": "pending", + "message": "Rigging task submitted", + }) + if wait: - return { - "task_id": result.id, - "status": result.status.value if hasattr(result.status, "value") else str(result.status), + return extend_data({ + "task_id": _result_get(result, "id"), + "status": _result_status(result), "message": "Rigging completed", - } + }) - return { - "task_id": result, # task_id string when wait=False - "status": "pending", - "message": "Rigging task submitted", - } + msg = "Expected rigging task id when wait=False" + raise TypeError(msg) -def apply_animation(model_id: str, animation_id: int, wait: bool = True) -> dict[str, Any]: +def apply_animation(model_id: str, animation_id: int, wait: bool = True) -> ExtendedDict: """Apply animation to a rigged model. Args: @@ -240,19 +278,23 @@ def apply_animation(model_id: str, animation_id: int, wait: bool = True) -> dict result = animate.apply(model_id, int(animation_id), wait=wait) + if isinstance(result, str): + return extend_data({ + "task_id": result, + "status": "pending", + "message": "Animation task submitted", + }) + if wait: - return { - "task_id": result.id, - "status": result.status.value if hasattr(result.status, "value") else str(result.status), + return extend_data({ + "task_id": _result_get(result, "id"), + "status": _result_status(result), "message": "Animation completed", - "glb_url": result.animation_glb_url, - } + "glb_url": _result_get(result, "animation_glb_url"), + }) - return { - "task_id": result, # task_id string when wait=False - "status": "pending", - "message": "Animation task submitted", - } + msg = "Expected animation task id when wait=False" + raise TypeError(msg) def retexture_model( @@ -260,7 +302,7 @@ def retexture_model( texture_prompt: str, enable_pbr: bool = True, wait: bool = True, -) -> dict[str, Any]: +) -> ExtendedDict: """Apply new textures to an existing model. Args: @@ -281,22 +323,26 @@ def retexture_model( wait=wait, ) + if isinstance(result, str): + return extend_data({ + "task_id": result, + "status": "pending", + "message": "Retexture task submitted", + }) + if wait: - return { - "task_id": result.id, - "status": result.status.value if hasattr(result.status, "value") else str(result.status), + return extend_data({ + "task_id": _result_get(result, "id"), + "status": _result_status(result), "message": "Retexture completed", - "model_url": getattr(result, "model_url", None), - } + "model_url": _result_get(result, "model_url"), + }) - return { - "task_id": result, # task_id string when wait=False - "status": "pending", - "message": "Retexture task submitted", - } + msg = "Expected retexture task id when wait=False" + raise TypeError(msg) -def list_animations(category: str = "", limit: int = 50) -> dict[str, Any]: +def list_animations(category: str = "", limit: int = 50) -> ExtendedDict: """List available animations from the Meshy catalog. Args: @@ -324,14 +370,14 @@ def list_animations(category: str = "", limit: int = 50) -> dict[str, Any]: } ) - return { + return extend_data({ "count": len(results), "total": len(animations), "animations": results, - } + }) -def check_task_status(task_id: str, task_type: str = "text-to-3d") -> dict[str, Any]: +def check_task_status(task_id: str, task_type: str = "text-to-3d") -> ExtendedDict: """Check status of a Meshy task. Args: @@ -344,7 +390,7 @@ def check_task_status(task_id: str, task_type: str = "text-to-3d") -> dict[str, from extended_data.connectors.meshy import animate, image3d, retexture, rigging, text3d # Call the appropriate get function based on task type - get_funcs = { + get_funcs: dict[str, Callable[[str], Any]] = { "text-to-3d": text3d.get, "image-to-3d": image3d.get, "rigging": rigging.get, @@ -357,24 +403,27 @@ def check_task_status(task_id: str, task_type: str = "text-to-3d") -> dict[str, raise ValueError(f"Unknown task type: {task_type}") result = get_func(task_id) - status = result.status.value if hasattr(result.status, "value") else str(result.status) + status = _result_status(result) # Get model URL if available model_url = None - if hasattr(result, "model_urls") and result.model_urls: - model_url = result.model_urls.glb - elif hasattr(result, "glb_url"): - model_url = result.glb_url - - return { + model_urls = _result_get(result, "model_urls") + if isinstance(model_urls, Mapping): + model_url = model_urls.get("glb") + elif model_urls: + model_url = getattr(model_urls, "glb", None) + if model_url is None: + model_url = _result_get(result, "glb_url") + + return extend_data({ "task_id": task_id, "status": status, - "progress": getattr(result, "progress", None), + "progress": _result_get(result, "progress"), "model_url": model_url, - } + }) -def get_animation(animation_id: int) -> dict[str, Any]: +def get_animation(animation_id: int) -> ExtendedDict: """Get details of a specific animation. Args: @@ -390,13 +439,13 @@ def get_animation(animation_id: int) -> dict[str, Any]: anim = ANIMATIONS[animation_id] - return { + return extend_data({ "id": anim.id, "name": anim.name, "category": anim.category, "subcategory": anim.subcategory, "preview_url": anim.preview_url, - } + }) # ============================================================================= @@ -498,21 +547,9 @@ def get_langchain_tools() -> list[Any]: Raises: ImportError: If langchain-core is not installed. """ - try: - from langchain_core.tools import StructuredTool - except ImportError as e: - msg = "langchain-core is required for LangChain tools.\nInstall with: pip install extended-data[langchain]" - raise ImportError(msg) from e - - return [ - StructuredTool.from_function( - func=defn["func"], - name=defn["name"], - description=defn["description"], - args_schema=defn.get("schema") or defn.get("args_schema"), - ) - for defn in TOOL_DEFINITIONS - ] + from extended_data.connectors.ai_tools import build_langchain_tools + + return build_langchain_tools(TOOL_DEFINITIONS) def get_crewai_tools() -> list[Any]: @@ -524,11 +561,9 @@ def get_crewai_tools() -> list[Any]: Raises: ImportError: If crewai is not installed. """ - try: - from crewai.tools import tool as crewai_tool - except ImportError as e: - msg = "crewai is required for CrewAI tools.\nInstall with: pip install extended-data[crewai]" - raise ImportError(msg) from e + from extended_data.connectors._optional import get_crewai_tool_decorator + + crewai_tool = get_crewai_tool_decorator() tools = [] for defn in TOOL_DEFINITIONS: @@ -567,7 +602,6 @@ def get_tools(framework: str = "auto") -> list[Any]: - "langchain": Force LangChain StructuredTools - "crewai": Force CrewAI tools - "strands": Force plain functions for Strands - - "functions": Force plain functions (alias for strands) Returns: List of tools in the appropriate format for the framework. @@ -587,7 +621,7 @@ def get_tools(framework: str = "auto") -> list[Any]: from extended_data.connectors._optional import is_available if framework == "auto": - # Priority: CrewAI > LangChain > Strands/functions + # Priority: CrewAI > LangChain > Strands # (CrewAI first since it's more opinionated about tool format) if is_available("crewai"): return get_crewai_tools() @@ -600,10 +634,10 @@ def get_tools(framework: str = "auto") -> list[Any]: return get_langchain_tools() if framework == "crewai": return get_crewai_tools() - if framework in ("strands", "functions"): + if framework == "strands": return get_strands_tools() - raise ValueError(f"Unknown framework: {framework}. Options: auto, langchain, crewai, strands, functions") + return raise_unknown_tool_framework(framework) # ============================================================================= diff --git a/src/extended_data/connectors/meshy/webhooks/handler.py b/src/extended_data/connectors/meshy/webhooks/handler.py index 290286e..784f93f 100644 --- a/src/extended_data/connectors/meshy/webhooks/handler.py +++ b/src/extended_data/connectors/meshy/webhooks/handler.py @@ -2,13 +2,16 @@ from __future__ import annotations +import base64 import hashlib +import hmac from datetime import datetime, timezone -from typing import Any from extended_data.connectors.meshy import base from extended_data.connectors.meshy.webhooks.schemas import MeshyWebhookPayload +from extended_data.containers import ExtendedDict, extend_data, to_builtin +from extended_data.primitives.redaction import redact_sensitive_text from ..persistence.repository import TaskRepository from ..persistence.schemas import ArtifactRecord @@ -30,19 +33,47 @@ def __init__( self, repository: TaskRepository, download_artifacts: bool = True, - ): + webhook_secret: str | bytes | None = None, + ) -> None: """Initialize webhook handler. Args: repository: TaskRepository for updating state download_artifacts: Whether to download GLB files on SUCCEEDED + webhook_secret: Shared secret used to verify HMAC-SHA256 signatures """ self.repository = repository self.download_artifacts = download_artifacts + self.webhook_secret = webhook_secret + + def handle_signed_webhook( + self, + payload: bytes, + signature: str, + project: str | None = None, + spec_hash: str | None = None, + ) -> ExtendedDict: + """Verify a raw webhook payload before parsing and processing it.""" + if not self.verify_signature(payload, signature): + return extend_data({ + "status": "error", + "message": "Invalid webhook signature", + }) + + try: + parsed_payload = MeshyWebhookPayload.model_validate_json(payload) + except ValueError as exc: + return extend_data({ + "status": "error", + "message": "Invalid webhook payload", + "error": redact_sensitive_text(exc), + }) + + return self.handle_webhook(parsed_payload, project=project, spec_hash=spec_hash) def handle_webhook( self, payload: MeshyWebhookPayload, project: str | None = None, spec_hash: str | None = None - ) -> dict[str, Any]: + ) -> ExtendedDict: """Process webhook payload and update repository. Args: @@ -51,37 +82,40 @@ def handle_webhook( spec_hash: Optional spec hash (will search if not provided) Returns: - Dict with status and details + Extended dict with status and details. """ task_lookup = self.repository.find_task_by_id(task_id=payload.id, project=project) if not task_lookup: - return { + return extend_data({ "status": "error", "message": f"Task {payload.id} not found in repository", "task_id": payload.id, - } + }) - found_project, found_spec_hash, asset_manifest = task_lookup + found_project = str(task_lookup["project"]) + found_spec_hash = str(task_lookup["spec_hash"]) + asset_manifest = task_lookup["asset"] service_name = None - for task_entry in asset_manifest.task_graph: - if task_entry.task_id == payload.id: - service_name = task_entry.service + for task_entry in asset_manifest.get("task_graph", []): + if task_entry["task_id"] == payload.id: + service_name = str(task_entry["service"]) break if not service_name: - return { + return extend_data({ "status": "error", "message": f"Task {payload.id} not found in task graph", "task_id": payload.id, - } + }) error_message = None if payload.status == "FAILED": - error_message = payload.get_error_message() + raw_error_message = payload.get_error_message() + error_message = redact_sensitive_text(raw_error_message) if raw_error_message else None - result_paths = payload.get_all_urls() + result_paths = to_builtin(payload.get_all_urls()) artifacts = [] if payload.status == "SUCCEEDED" and self.download_artifacts: @@ -107,7 +141,7 @@ def handle_webhook( error=error_message, ) - return { + return extend_data({ "status": "success", "task_id": payload.id, "project": found_project, @@ -115,7 +149,7 @@ def handle_webhook( "service": service_name, "task_status": payload.status, "artifacts_downloaded": len(artifacts), - } + }) def _download_glb_artifact(self, project: str, spec_hash: str, service: str, glb_url: str) -> ArtifactRecord | None: """Download GLB artifact and create record.""" @@ -140,6 +174,33 @@ def _download_glb_artifact(self, project: str, spec_hash: str, service: str, glb except Exception: return None - def verify_signature(self, payload: bytes, signature: str) -> bool: - """Verify webhook signature (stubbed for testing).""" - return True # Stub for testing + def verify_signature( + self, + payload: bytes, + signature: str, + *, + secret: str | bytes | None = None, + ) -> bool: + """Verify an HMAC-SHA256 webhook signature for a raw payload.""" + secret_value = self.webhook_secret if secret is None else secret + if secret_value is None or not signature.strip(): + return False + + secret_bytes = secret_value.encode("utf-8") if isinstance(secret_value, str) else secret_value + if not secret_bytes: + return False + + digest = hmac.new(secret_bytes, payload, hashlib.sha256).digest() + expected_hex = digest.hex() + expected_base64 = base64.b64encode(digest).decode("ascii") + expected_urlsafe_base64 = base64.urlsafe_b64encode(digest).decode("ascii") + + signature_value = signature.strip() + if signature_value.casefold().startswith("sha256="): + signature_value = signature_value.split("=", 1)[1].strip() + + return ( + hmac.compare_digest(signature_value.casefold(), expected_hex) + or hmac.compare_digest(signature_value, expected_base64) + or hmac.compare_digest(signature_value, expected_urlsafe_base64) + ) diff --git a/src/extended_data/connectors/meshy/webhooks/schemas.py b/src/extended_data/connectors/meshy/webhooks/schemas.py index f6eee24..4f0de68 100644 --- a/src/extended_data/connectors/meshy/webhooks/schemas.py +++ b/src/extended_data/connectors/meshy/webhooks/schemas.py @@ -4,6 +4,8 @@ from pydantic import BaseModel, Field +from extended_data.containers import ExtendedDict, extend_data + class WebhookModelUrls(BaseModel): """Model URLs in webhook payload.""" @@ -107,8 +109,8 @@ def get_glb_url(self) -> str | None: return None - def get_all_urls(self) -> dict[str, str]: - """Get all available URLs as a flat dict.""" + def get_all_urls(self) -> ExtendedDict: + """Get all available URLs as an extended flat dict.""" urls = {} # Model URLs @@ -143,4 +145,4 @@ def get_all_urls(self) -> dict[str, str]: if self.thumbnail_url: urls["thumbnail"] = self.thumbnail_url - return urls + return extend_data(urls) diff --git a/src/extended_data/connectors/registry.py b/src/extended_data/connectors/registry.py index 3853044..143919b 100644 --- a/src/extended_data/connectors/registry.py +++ b/src/extended_data/connectors/registry.py @@ -1,19 +1,20 @@ -"""Vendor Connector Registry with Entry Points. +"""Connector Registry with Entry Points. This module provides automatic discovery and registration of extended data connectors using Python's entry points system. This allows: -1. DRY interface via VendorConnectorBase ABC +1. DRY interface via ConnectorBase ABC 2. Automatic discovery of all connectors (even from other packages) 3. Unified factory function for instantiation 4. Same registry used by both MCP and CLI Usage: - from extended_data.connectors.registry import get_connector, list_connectors + from extended_data.connectors.registry import get_connector, list_available_connectors, list_connectors - # List available connectors - available = list_connectors() - # {'jules': , 'cursor': , ...} + # List catalog connectors or only runtime-ready connectors + catalog = list_connectors() + available = list_available_connectors() + # ExtendedList(["anthropic", "aws", "cursor", ...]) # Get a specific connector instance connector = get_connector('jules', api_key='...') @@ -35,9 +36,18 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, NoReturn +from extended_data.connectors._optional import ( + get_connector_install_command, + get_connector_requirements, + get_extra_for_connector, + get_missing_connector_requirements, +) +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString, extend_data +from extended_data.primitives.redaction import redact_sensitive_text + if TYPE_CHECKING: - from extended_data.connectors.base import VendorConnectorBase + from extended_data.connectors.base import ConnectorBase @dataclass(frozen=True) @@ -47,29 +57,134 @@ class BuiltinConnectorSpec: module_path: str class_name: str extra: str + category: str = "external" + capabilities: tuple[str, ...] = () + + +@dataclass(frozen=True) +class ConnectorInfo: + """Registry metadata for a connector.""" + + name: str + available: bool + source: str + extra: str | None + category: str + capabilities: tuple[str, ...] + install: str | None + requirements: tuple[str, ...] + missing: tuple[str, ...] + class_name: str | None + module: str | None + base_url: str | None + description: str | None + error: str | None + + def as_dict(self) -> ExtendedDict: + """Return extended JSON-friendly connector metadata.""" + return extend_data({ + "name": self.name, + "available": self.available, + "source": self.source, + "extra": self.extra, + "category": self.category, + "capabilities": list(self.capabilities), + "install": self.install, + "requirements": list(self.requirements), + "missing": list(self.missing), + "class": self.class_name, + "module": self.module, + "base_url": self.base_url, + "description": self.description, + "error": self.error, + }) BUILTIN_CONNECTORS: dict[str, BuiltinConnectorSpec] = { # Google connectors - "jules": BuiltinConnectorSpec("extended_data.connectors.google.jules", "JulesConnector", "google"), - "google": BuiltinConnectorSpec("extended_data.connectors.google", "GoogleConnector", "google"), - "google_cloud": BuiltinConnectorSpec("extended_data.connectors.google", "GoogleCloudConnector", "google"), - "google_workspace": BuiltinConnectorSpec("extended_data.connectors.google", "GoogleWorkspaceConnector", "google"), - "google_billing": BuiltinConnectorSpec("extended_data.connectors.google", "GoogleBillingConnector", "google"), + "jules": BuiltinConnectorSpec( + "extended_data.connectors.google.jules", + "JulesConnector", + "google", + category="ai", + capabilities=("sources", "sessions"), + ), + "google": BuiltinConnectorSpec( + "extended_data.connectors.google", + "GoogleConnector", + "google", + category="cloud", + capabilities=("workspace", "cloud", "billing", "services", "iam"), + ), # Other connectors - "cursor": BuiltinConnectorSpec("extended_data.connectors.cursor", "CursorConnector", "cursor"), - "github": BuiltinConnectorSpec("extended_data.connectors.github", "GitHubConnector", "github"), - "meshy": BuiltinConnectorSpec("extended_data.connectors.meshy", "MeshyConnector", "meshy"), - "anthropic": BuiltinConnectorSpec("extended_data.connectors.anthropic", "AnthropicConnector", "anthropic"), - "aws": BuiltinConnectorSpec("extended_data.connectors.aws", "AWSConnector", "aws"), - "slack": BuiltinConnectorSpec("extended_data.connectors.slack", "SlackConnector", "slack"), - "zoom": BuiltinConnectorSpec("extended_data.connectors.zoom", "ZoomConnector", "zoom"), - "vault": BuiltinConnectorSpec("extended_data.connectors.vault", "VaultConnector", "vault"), + "cursor": BuiltinConnectorSpec( + "extended_data.connectors.cursor", + "CursorConnector", + "cursor", + category="ai", + capabilities=("agents", "repositories", "models"), + ), + "github": BuiltinConnectorSpec( + "extended_data.connectors.github", + "GitHubConnector", + "github", + category="development", + capabilities=("repositories", "teams", "files", "graphql", "workflows"), + ), + "meshy": BuiltinConnectorSpec( + "extended_data.connectors.meshy", + "MeshyConnector", + "meshy", + category="media", + capabilities=("3d-generation", "animation", "rigging", "retexturing", "metadata"), + ), + "secrets": BuiltinConnectorSpec( + "extended_data.connectors.secrets", + "SecretsConnector", + "secrets", + category="secrets", + capabilities=("pipeline", "dry-run", "merge", "validation"), + ), + "anthropic": BuiltinConnectorSpec( + "extended_data.connectors.anthropic", + "AnthropicConnector", + "anthropic", + category="ai", + capabilities=("messages", "models", "tools"), + ), + "aws": BuiltinConnectorSpec( + "extended_data.connectors.aws", + "AWSConnector", + "aws", + category="cloud", + capabilities=("identity", "secrets", "storage", "organizations", "sso"), + ), + "slack": BuiltinConnectorSpec( + "extended_data.connectors.slack", + "SlackConnector", + "slack", + category="communications", + capabilities=("messages", "channels", "users", "usergroups"), + ), + "zoom": BuiltinConnectorSpec( + "extended_data.connectors.zoom", + "ZoomConnector", + "zoom", + category="communications", + capabilities=("users", "meetings"), + ), + "vault": BuiltinConnectorSpec( + "extended_data.connectors.vault", + "VaultConnector", + "vault", + category="secrets", + capabilities=("kv", "aws-iam", "leases"), + ), } # Cache for discovered connectors -_connector_cache: dict[str, builtins.type[VendorConnectorBase]] | None = None +_connector_cache: dict[str, builtins.type[ConnectorBase]] | None = None _missing_builtin_connectors: dict[str, ImportError] = {} @@ -78,14 +193,19 @@ def _normalize_connector_name(name: str) -> str: return name.strip().lower() -def _discover_connectors() -> dict[str, builtins.type[VendorConnectorBase]]: +def _normalize_catalog_token(value: object) -> str: + """Normalize connector catalog categories and capabilities.""" + return str(value).strip().lower().replace("_", "-") + + +def _discover_connectors() -> dict[str, builtins.type[ConnectorBase]]: """Discover all registered connectors via entry points.""" global _connector_cache if _connector_cache is not None: return _connector_cache - connectors: dict[str, builtins.type[VendorConnectorBase]] = {} + connectors: dict[str, builtins.type[ConnectorBase]] = {} # Python 3.10+ uses importlib.metadata from importlib.metadata import entry_points @@ -93,62 +213,80 @@ def _discover_connectors() -> dict[str, builtins.type[VendorConnectorBase]]: eps = entry_points(group="extended_data.connectors") for ep in eps: + connector_name = _normalize_connector_name(ep.name) try: - connectors[ep.name] = ep.load() + connectors[connector_name] = ep.load() + _missing_builtin_connectors.pop(connector_name, None) + except ImportError as e: + if connector_name in BUILTIN_CONNECTORS: + _missing_builtin_connectors[connector_name] = e + continue + import warnings + + warnings.warn( + f"Failed to load connector '{redact_sensitive_text(ep.name)}': {redact_sensitive_text(e)}", + stacklevel=2, + ) except Exception as e: # Log but don't fail - allow partial loading import warnings - warnings.warn(f"Failed to load connector '{ep.name}': {e}", stacklevel=2) - - # Also include built-in connectors not yet in entry points - # (for development/transition period) - _register_builtins(connectors) + warnings.warn( + f"Failed to load connector '{redact_sensitive_text(ep.name)}': {redact_sensitive_text(e)}", + stacklevel=2, + ) _connector_cache = connectors return connectors -def _register_builtins(connectors: dict[str, builtins.type[VendorConnectorBase]]) -> None: - """Register built-in connectors that may not be in entry points yet.""" - for name, spec in BUILTIN_CONNECTORS.items(): - if name in connectors: - _missing_builtin_connectors.pop(name, None) - continue # Entry point takes precedence - try: - import importlib - - module = importlib.import_module(spec.module_path) - cls = getattr(module, spec.class_name, None) - if cls is not None: - connectors[name] = cls - _missing_builtin_connectors.pop(name, None) - except ImportError as e: - _missing_builtin_connectors[name] = e # Optional dependency not installed - - def _raise_missing_builtin_connector(name: str, error: ImportError) -> NoReturn: """Raise a clear install hint for a known built-in connector.""" - spec = BUILTIN_CONNECTORS[name] + install = str(get_connector_install_command(name) or f"pip install extended-data[{BUILTIN_CONNECTORS[name].extra}]") + missing = get_missing_connector_requirements(name) msg = ( f"The '{name}' connector is built in but its optional dependencies are not installed.\n" - f"Install with: pip install extended-data[{spec.extra}]" + f"Install with: {install}" ) + if missing: + msg = f"{msg}\nMissing packages: {', '.join(str(package) for package in missing)}" if str(error): - msg = f"{msg}\nOriginal import error: {error}" + msg = f"{msg}\nOriginal import error: {redact_sensitive_text(error)}" raise ImportError(msg) from error -def list_connectors() -> dict[str, builtins.type[VendorConnectorBase]]: - """List all available connectors. +def _raise_unregistered_builtin_connector(name: str) -> NoReturn: + """Raise a packaging error when a declared built-in connector has no entry point.""" + spec = BUILTIN_CONNECTORS[name] + raise RuntimeError( + f"The built-in '{name}' connector is declared but is not registered in the " + "extended_data.connectors entry point group. " + f'Expected: {name} = "{spec.module_path}:{spec.class_name}"' + ) + + +def _list_connector_classes() -> dict[str, builtins.type[ConnectorBase]]: + """List available connector classes for internal tool registration.""" + return _discover_connectors().copy() + + +def list_connectors(*, include_unavailable: bool = True) -> ExtendedList[ExtendedString]: + """List connector catalog names. Returns: - Dict mapping connector name to connector class. + ExtendedList of known connector registry names. """ - return _discover_connectors().copy() + return extend_data( + [str(connector["name"]) for connector in list_connector_info(include_unavailable=include_unavailable)], + ) + + +def list_available_connectors() -> ExtendedList[ExtendedString]: + """List connector names whose runtime requirements are installed.""" + return list_connectors(include_unavailable=False) -def get_connector_class(name: str) -> builtins.type[VendorConnectorBase]: +def get_connector_class(name: str) -> builtins.type[ConnectorBase]: """Get a connector class by name. Args: @@ -166,13 +304,21 @@ def get_connector_class(name: str) -> builtins.type[VendorConnectorBase]: if name_lower not in connectors: if name_lower in _missing_builtin_connectors: _raise_missing_builtin_connector(name_lower, _missing_builtin_connectors[name_lower]) + if name_lower in BUILTIN_CONNECTORS: + _raise_unregistered_builtin_connector(name_lower) available = ", ".join(sorted(connectors.keys())) - raise ValueError(f"Unknown connector: {name}. Available: {available}") + raise ValueError(f"Unknown connector: {redact_sensitive_text(name)}. Available: {available}") + + if name_lower in BUILTIN_CONNECTORS: + missing = get_missing_connector_requirements(name_lower) + if missing: + error = ImportError(f"Missing packages: {', '.join(str(package) for package in missing)}") + _raise_missing_builtin_connector(name_lower, error) return connectors[name_lower] -def get_connector(name: str, **kwargs: Any) -> VendorConnectorBase: +def get_connector(name: str, **kwargs: Any) -> ConnectorBase: """Factory to instantiate a connector by name. Args: @@ -200,29 +346,170 @@ def clear_cache() -> None: _missing_builtin_connectors.clear() +def _get_description(cls: builtins.type[ConnectorBase]) -> str | None: + """Get the first useful line from a connector docstring.""" + if not cls.__doc__: + return None + for line in cls.__doc__.splitlines(): + description = line.strip() + if description: + return description + return None + + +def _get_category(cls: builtins.type[ConnectorBase], spec: BuiltinConnectorSpec | None) -> str: + """Get normalized category metadata for a connector.""" + raw_category = spec.category if spec else getattr(cls, "CONNECTOR_CATEGORY", "external") + return _normalize_catalog_token(raw_category) or "external" + + +def _get_capabilities(cls: builtins.type[ConnectorBase], spec: BuiltinConnectorSpec | None) -> tuple[str, ...]: + """Get normalized capability metadata for a connector.""" + raw_capabilities = spec.capabilities if spec else getattr(cls, "CONNECTOR_CAPABILITIES", ()) + capability_values = (raw_capabilities,) if isinstance(raw_capabilities, str) else raw_capabilities + capabilities = [_normalize_catalog_token(capability) for capability in capability_values] + capabilities = [capability for capability in capabilities if capability] + return tuple(dict.fromkeys(capabilities)) + + +def _available_connector_info(name: str, cls: builtins.type[ConnectorBase]) -> ConnectorInfo: + """Build metadata for a loadable connector.""" + spec = BUILTIN_CONNECTORS.get(name) + source = "builtin" if spec else "entry_point" + extra_value = spec.extra if spec else get_extra_for_connector(name) + extra = str(extra_value) if extra_value is not None else None + requirements = tuple(str(requirement) for requirement in get_connector_requirements(name)) + missing = tuple(str(requirement) for requirement in get_missing_connector_requirements(name)) + install_value = get_connector_install_command(name) + + return ConnectorInfo( + name=name, + available=not missing, + source=source, + extra=extra, + category=_get_category(cls, spec), + capabilities=_get_capabilities(cls, spec), + install=str(install_value) if install_value is not None else None, + requirements=requirements, + missing=missing, + class_name=cls.__name__, + module=cls.__module__, + base_url=getattr(cls, "BASE_URL", None), + description=_get_description(cls), + error=None, + ) + + +def _missing_builtin_connector_info(name: str, error: ImportError | None) -> ConnectorInfo: + """Build metadata for a known built-in connector that cannot be loaded.""" + spec = BUILTIN_CONNECTORS[name] + error_message = ( + redact_sensitive_text(error) + if error + else "Built-in connector is declared but is not registered in the extended_data.connectors entry point group." + ) + + return ConnectorInfo( + name=name, + available=False, + source="builtin", + extra=spec.extra, + category=spec.category, + capabilities=spec.capabilities, + install=str(install) if (install := get_connector_install_command(name)) is not None else None, + requirements=tuple(str(requirement) for requirement in get_connector_requirements(name)), + missing=tuple(str(requirement) for requirement in get_missing_connector_requirements(name)), + class_name=spec.class_name, + module=spec.module_path, + base_url=None, + description=None, + error=error_message, + ) + + # ============================================================================= # Connector Info Helpers # ============================================================================= -def get_connector_info(name: str) -> dict[str, Any]: - """Get metadata about a connector. +def get_connector_info(name: str, *, include_unavailable: bool = True) -> ExtendedDict: + """Get registry metadata about a connector.""" + connector_name = _normalize_connector_name(name) + connectors = _discover_connectors() - Returns: - Dict with name, module, env_vars, description, etc. - """ - cls = get_connector_class(name) + if connector_name in connectors: + return _available_connector_info(connector_name, connectors[connector_name]).as_dict() + + if connector_name in _missing_builtin_connectors: + if include_unavailable: + return _missing_builtin_connector_info(connector_name, _missing_builtin_connectors[connector_name]).as_dict() + _raise_missing_builtin_connector(connector_name, _missing_builtin_connectors[connector_name]) - return { - "name": name, - "class": cls.__name__, - "module": cls.__module__, - "base_url": getattr(cls, "BASE_URL", None), - "api_key_env": getattr(cls, "API_KEY_ENV", None), - "description": cls.__doc__.split("\n")[0] if cls.__doc__ else None, + if include_unavailable and connector_name in BUILTIN_CONNECTORS: + return _missing_builtin_connector_info(connector_name, None).as_dict() + + available = ", ".join(sorted(connectors.keys())) + raise ValueError(f"Unknown connector: {redact_sensitive_text(name)}. Available: {available}") + + +def list_connector_info(*, include_unavailable: bool = True) -> ExtendedList[ExtendedDict]: + """Get registry metadata for known connectors.""" + connectors = _discover_connectors() + names = set(connectors) + if include_unavailable: + names.update(BUILTIN_CONNECTORS) + names.update(_missing_builtin_connectors) + info = [get_connector_info(name, include_unavailable=include_unavailable) for name in sorted(names)] + if not include_unavailable: + return extend_data([connector for connector in info if connector["available"]]) + return extend_data(info) + + +def list_connector_categories(*, include_unavailable: bool = True) -> ExtendedList[ExtendedString]: + """List connector catalog categories.""" + categories = { + str(connector["category"]) + for connector in list_connector_info(include_unavailable=include_unavailable) + if connector["category"] } + return extend_data(sorted(categories)) + + +def list_connector_capabilities(*, include_unavailable: bool = True) -> ExtendedList[ExtendedString]: + """List connector catalog capabilities.""" + capabilities: set[str] = set() + for connector in list_connector_info(include_unavailable=include_unavailable): + capabilities.update(str(capability) for capability in connector["capabilities"]) + return extend_data(sorted(capabilities)) + + +def list_connectors_by_category( + category: str, + *, + include_unavailable: bool = True, +) -> ExtendedList[ExtendedDict]: + """List connector catalog entries for a category.""" + normalized = _normalize_catalog_token(category) + return extend_data( + [ + connector + for connector in list_connector_info(include_unavailable=include_unavailable) + if str(connector["category"]) == normalized + ], + ) -def list_connector_info() -> list[dict[str, Any]]: - """Get metadata for all connectors.""" - return [get_connector_info(name) for name in sorted(list_connectors().keys())] +def list_connectors_by_capability( + capability: str, + *, + include_unavailable: bool = True, +) -> ExtendedList[ExtendedDict]: + """List connector catalog entries for a capability.""" + normalized = _normalize_catalog_token(capability) + return extend_data( + [ + connector + for connector in list_connector_info(include_unavailable=include_unavailable) + if normalized in {str(value) for value in connector["capabilities"]} + ], + ) diff --git a/src/extended_data/connectors/secrets/__init__.py b/src/extended_data/connectors/secrets/__init__.py index 38602f0..75ada2c 100644 --- a/src/extended_data/connectors/secrets/__init__.py +++ b/src/extended_data/connectors/secrets/__init__.py @@ -1,13 +1,13 @@ -"""Secrets Connector - Enterprise-grade secret synchronization. +"""Secrets Connector - enterprise-grade SecretSync integration. -This connector provides Python bindings for secretssync, enabling -enterprise-grade secret synchronization from HashiCorp Vault to -AWS Secrets Manager with two-phase architecture, inheritance, -versioning, and CI/CD integration. +This connector integrates with the standalone SecretSync project +(`jbcom/secrets-sync`), enabling enterprise-grade secret synchronization from +HashiCorp Vault to AWS Secrets Manager with two-phase architecture, +inheritance, versioning, and CI/CD integration. -The connector can operate in two modes: -1. Native mode: Uses gopy-generated Python bindings for maximum performance -2. CLI mode: Falls back to subprocess calls if bindings aren't available +The connector executes the supported `secretsync` subprocess CLI contract. +Alternate runtime transports should be added only after SecretSync publishes a +stable runtime contract. Example usage: from extended_data.connectors.secrets import SecretsConnector @@ -16,40 +16,35 @@ connector = SecretsConnector() # Validate a configuration - is_valid, message = connector.validate_config("pipeline.yaml") + validation = connector.validate_config("pipeline.yaml") # Run a dry-run to see what would change result = connector.dry_run("pipeline.yaml") - print(f"Would sync {result.secrets_processed} secrets") + print(f"Would sync {result['secrets_processed']} secrets") # Execute the full pipeline result = connector.run_pipeline("pipeline.yaml") - if result.success: - print(f"Synced {result.secrets_added} secrets") + if result["success"]: + print(f"Synced {result['secrets_added']} secrets") """ from __future__ import annotations -import json import shutil import subprocess -from dataclasses import dataclass, field +from dataclasses import asdict, dataclass, field from enum import Enum from pathlib import Path +from typing import Any -from extended_data.connectors.base import VendorConnectorBase +from extended_data.connectors.base import ConnectorBase +from extended_data.containers import ExtendedDict, extend_data, to_builtin +from extended_data.io import DataFile, wrap_raw_data_for_export +from extended_data.io.files import decode_file from extended_data.logging import Logging - - -# Try to import native bindings -_NATIVE_AVAILABLE = False -try: - import secretssync as _native - - _NATIVE_AVAILABLE = True -except ImportError: - _native = None +from extended_data.primitives.formats.errors import DataDecodeError +from extended_data.primitives.redaction import redact_sensitive_data, redact_sensitive_text class SyncOperation(str, Enum): @@ -77,10 +72,10 @@ class SyncOptions: dry_run: bool = False operation: SyncOperation = SyncOperation.PIPELINE targets: list[str] = field(default_factory=list) - continue_on_error: bool = False - parallelism: int = 4 + continue_on_error: bool = True + parallelism: int = 0 compute_diff: bool = False - output_format: OutputFormat = OutputFormat.HUMAN + output_format: OutputFormat = OutputFormat.JSON @dataclass @@ -100,39 +95,31 @@ class SyncResult: diff_output: str = "" @classmethod - def from_native(cls, native_result) -> SyncResult: - """Create from native gopy result.""" - return cls( - success=native_result.Success, - target_count=native_result.TargetCount, - secrets_processed=native_result.SecretsProcessed, - secrets_added=native_result.SecretsAdded, - secrets_modified=native_result.SecretsModified, - secrets_removed=native_result.SecretsRemoved, - secrets_unchanged=native_result.SecretsUnchanged, - duration_ms=native_result.DurationMs, - error_message=native_result.ErrorMessage, - results_json=native_result.ResultsJSON, - diff_output=native_result.DiffOutput, - ) - - @classmethod - def from_cli_output(cls, output: dict) -> SyncResult: + def from_cli_output(cls, output: dict[str, Any]) -> SyncResult: """Create from CLI JSON output.""" + safe_output = redact_sensitive_data(output) return cls( - success=output.get("success", False), - target_count=output.get("target_count", 0), - secrets_processed=output.get("secrets_processed", 0), - secrets_added=output.get("secrets_added", 0), - secrets_modified=output.get("secrets_modified", 0), - secrets_removed=output.get("secrets_removed", 0), - secrets_unchanged=output.get("secrets_unchanged", 0), - duration_ms=output.get("duration_ms", 0), - error_message=output.get("error_message", ""), - results_json=json.dumps(output.get("results", [])), - diff_output=output.get("diff_output", ""), + success=safe_output.get("success", False), + target_count=safe_output.get("target_count", 0), + secrets_processed=safe_output.get("secrets_processed", 0), + secrets_added=safe_output.get("secrets_added", 0), + secrets_modified=safe_output.get("secrets_modified", 0), + secrets_removed=safe_output.get("secrets_removed", 0), + secrets_unchanged=safe_output.get("secrets_unchanged", 0), + duration_ms=safe_output.get("duration_ms", 0), + error_message=safe_output.get("error_message", ""), + results_json=wrap_raw_data_for_export( + safe_output.get("results", []), + allow_encoding="json", + indent_2=True, + ), + diff_output=safe_output.get("diff_output", ""), ) + def to_dict(self) -> ExtendedDict: + """Return an extended sync result payload.""" + return extend_data(asdict(self)) + @dataclass class ConfigInfo: @@ -148,28 +135,16 @@ class ConfigInfo: vault_address: str = "" aws_region: str = "" - @classmethod - def from_native(cls, native_info) -> ConfigInfo: - """Create from native gopy result.""" - return cls( - valid=native_info.Valid, - error_message=native_info.ErrorMessage, - source_count=native_info.SourceCount, - target_count=native_info.TargetCount, - sources=list(native_info.Sources) if native_info.Sources else [], - targets=list(native_info.Targets) if native_info.Targets else [], - has_merge_store=native_info.HasMergeStore, - vault_address=native_info.VaultAddress, - aws_region=native_info.AWSRegion, - ) + def to_dict(self) -> ExtendedDict: + """Return an extended config info payload.""" + return extend_data(asdict(self)) -class SecretsConnector(VendorConnectorBase): - """Enterprise-grade secret synchronization connector. +class SecretsConnector(ConnectorBase): + """Enterprise-grade SecretSync connector. - This connector wraps the secretssync Go library, providing Python - bindings for enterprise-grade secret synchronization between - HashiCorp Vault and AWS Secrets Manager. + This connector wraps the standalone SecretSync project + (`jbcom/secrets-sync`) through the supported `secretsync` CLI. Features: - Two-phase pipeline architecture (merge → sync) @@ -178,36 +153,31 @@ class SecretsConnector(VendorConnectorBase): - Dry-run with visual diff output - CI/CD integration with exit codes - The connector operates in two modes: - 1. Native mode: Uses gopy-generated bindings (faster) - 2. CLI mode: Falls back to subprocess if bindings unavailable + Alternate runtime transports are intentionally not accepted here until + SecretSync publishes a stable runtime contract. """ def __init__( self, cli_path: str | None = None, - prefer_native: bool = True, logger: Logging | None = None, - **kwargs, - ): + **kwargs: Any, + ) -> None: """Initialize the secrets connector. Args: cli_path: Path to secretsync CLI binary (for CLI mode) - prefer_native: Prefer native bindings over CLI logger: Logger instance - **kwargs: Passed to VendorConnectorBase + **kwargs: Passed to ConnectorBase """ super().__init__(logger=logger, **kwargs) - self._prefer_native = prefer_native and _NATIVE_AVAILABLE self._cli_path = cli_path or self._find_cli() - mode = "native" if self._prefer_native else "CLI" - self.logger.info(f"SecretsConnector initialized in {mode} mode") + self.logger.info("SecretsConnector initialized in CLI mode") def _find_cli(self) -> str | None: - """Find the secretsync CLI binary.""" + """Find the SecretSync `secretsync` CLI binary.""" # Check common locations candidates = [ "secretsync", @@ -222,29 +192,27 @@ def _find_cli(self) -> str | None: return None - @property - def native_available(self) -> bool: - """Check if native bindings are available.""" - return _NATIVE_AVAILABLE - @property def cli_available(self) -> bool: """Check if CLI is available.""" return self._cli_path is not None - def validate_config(self, config_path: str) -> tuple[bool, str]: + def validate_config(self, config_path: str) -> ExtendedDict: """Validate a pipeline configuration file. Args: config_path: Path to YAML configuration file Returns: - Tuple of (is_valid, message) + Extended validation payload. """ - if self._prefer_native: - return _native.ValidateConfig(config_path) + is_valid, message = self._cli_validate_config(config_path) - return self._cli_validate_config(config_path) + return extend_data({ + "valid": is_valid, + "message": message, + "config_path": config_path, + }) def _cli_validate_config(self, config_path: str) -> tuple[bool, str]: """Validate config via CLI.""" @@ -261,62 +229,64 @@ def _cli_validate_config(self, config_path: str) -> tuple[bool, str]: ) if result.returncode == 0: return True, "Configuration is valid" - return False, result.stderr or result.stdout + return False, redact_sensitive_text(result.stderr or result.stdout) except subprocess.TimeoutExpired: return False, "Validation timed out" except Exception as e: - return False, str(e) + return False, redact_sensitive_text(e) - def get_config_info(self, config_path: str) -> ConfigInfo: + def get_config_info(self, config_path: str) -> ExtendedDict: """Get detailed information about a configuration. Args: config_path: Path to YAML configuration file Returns: - ConfigInfo with configuration details + Extended configuration details payload. """ - if self._prefer_native: - native_info = _native.GetConfigInfo(config_path) - return ConfigInfo.from_native(native_info) - - return self._cli_get_config_info(config_path) + return self._cli_get_config_info(config_path).to_dict() def _cli_get_config_info(self, config_path: str) -> ConfigInfo: """Get config info via CLI.""" try: - import yaml - except ImportError: - return ConfigInfo(error_message="pyyaml is required for CLI mode but not installed.") - - try: - with open(config_path) as f: - cfg = yaml.safe_load(f) + cfg = to_builtin(DataFile.read(config_path, suffix="yaml", as_extended=True).data) if not isinstance(cfg, dict): # Handles empty file (cfg=None) or non-dict root cfg = {} + sources = cfg.get("sources", {}) + if not isinstance(sources, dict): + sources = {} + targets = cfg.get("targets", {}) + if not isinstance(targets, dict): + targets = {} + vault = cfg.get("vault", {}) + if not isinstance(vault, dict): + vault = {} + aws = cfg.get("aws", {}) + if not isinstance(aws, dict): + aws = {} return ConfigInfo( valid=True, - source_count=len(cfg.get("sources", {})), - target_count=len(cfg.get("targets", {})), - sources=list(cfg.get("sources", {}).keys()), - targets=list(cfg.get("targets", {}).keys()), + source_count=len(sources), + target_count=len(targets), + sources=list(sources.keys()), + targets=list(targets.keys()), has_merge_store="merge_store" in cfg, - vault_address=cfg.get("vault", {}).get("address", ""), - aws_region=cfg.get("aws", {}).get("region", ""), + vault_address=vault.get("address", ""), + aws_region=aws.get("region", ""), ) except FileNotFoundError: - return ConfigInfo(error_message=f"Configuration file not found: {config_path}") - except yaml.YAMLError as e: - return ConfigInfo(error_message=f"Error parsing YAML file: {e}") + return ConfigInfo(error_message=f"Configuration file not found: {redact_sensitive_text(config_path)}") + except DataDecodeError as e: + return ConfigInfo(error_message=f"Error parsing YAML file: {redact_sensitive_text(e)}") def run_pipeline( self, config_path: str, options: SyncOptions | None = None, - ) -> SyncResult: + ) -> ExtendedDict: """Execute the secrets synchronization pipeline. Args: @@ -324,32 +294,11 @@ def run_pipeline( options: Execution options (defaults to full pipeline) Returns: - SyncResult with operation details + Extended sync result payload. """ options = options or SyncOptions() - if self._prefer_native: - return self._native_run_pipeline(config_path, options) - - return self._cli_run_pipeline(config_path, options) - - def _native_run_pipeline( - self, - config_path: str, - options: SyncOptions, - ) -> SyncResult: - """Run pipeline via native bindings.""" - native_opts = _native.DefaultSyncOptions() - native_opts.DryRun = options.dry_run - native_opts.Operation = options.operation.value - native_opts.Targets = ",".join(options.targets) - native_opts.ContinueOnError = options.continue_on_error - native_opts.Parallelism = options.parallelism - native_opts.ComputeDiff = options.compute_diff - native_opts.OutputFormat = options.output_format.value - - native_result = _native.RunPipeline(config_path, native_opts) - return SyncResult.from_native(native_result) + return self._cli_run_pipeline(config_path, options).to_dict() def _cli_run_pipeline( self, @@ -360,9 +309,11 @@ def _cli_run_pipeline( if not self._cli_path: return SyncResult( success=False, - error_message="CLI not available and native bindings not installed", + error_message="secretsync CLI not available", ) + # Always request JSON so this Python surface can reliably return a + # structured SyncResult from the supported CLI contract. cmd = [ self._cli_path, "pipeline", @@ -381,13 +332,10 @@ def _cli_run_pipeline( cmd.append("--dry-run") if options.compute_diff: cmd.append("--diff") - if options.output_format: - cmd.extend(["--output", options.output_format.value]) if options.targets: cmd.extend(["--targets", ",".join(options.targets)]) - if options.continue_on_error: - cmd.append("--continue-on-error") - if options.parallelism: + cmd.append(f"--continue-on-error={str(options.continue_on_error).lower()}") + if options.parallelism > 0: cmd.extend(["--parallelism", str(options.parallelism)]) try: @@ -399,47 +347,71 @@ def _cli_run_pipeline( check=False, ) + stdout = result.stdout.strip() + if stdout: + try: + output = to_builtin(decode_file(stdout, suffix="json", as_extended=True)) + except DataDecodeError as e: + if result.returncode == 0: + return SyncResult( + success=False, + error_message=f"Failed to parse output: {redact_sensitive_text(e)}", + ) + else: + if not isinstance(output, dict) or "success" not in output: + return SyncResult( + success=False, + error_message=( + "Unsupported secretsync JSON output: expected pipeline result envelope. " + "Upgrade secretsync to a version that emits the stable result envelope." + ), + ) + parsed = SyncResult.from_cli_output(output) + if result.returncode != 0 and not parsed.error_message: + parsed.error_message = redact_sensitive_text( + result.stderr or f"secretsync exited with status {result.returncode}" + ) + return parsed + if result.returncode == 0: - output = json.loads(result.stdout) - return SyncResult.from_cli_output(output) - else: return SyncResult( success=False, - error_message=result.stderr or result.stdout, + error_message="secretsync produced no JSON output", ) + + return SyncResult( + success=False, + error_message=redact_sensitive_text(result.stderr or result.stdout), + ) except subprocess.TimeoutExpired: return SyncResult( success=False, error_message="Pipeline execution timed out", ) - except json.JSONDecodeError as e: + except DataDecodeError as e: return SyncResult( success=False, - error_message=f"Failed to parse output: {e}", + error_message=f"Failed to parse output: {redact_sensitive_text(e)}", ) except Exception as e: return SyncResult( success=False, - error_message=str(e), + error_message=redact_sensitive_text(e), ) - def dry_run(self, config_path: str) -> SyncResult: + def dry_run(self, config_path: str) -> ExtendedDict: """Perform a dry run of the pipeline. Args: config_path: Path to YAML configuration file Returns: - SyncResult with what would be changed + Extended dry-run result payload. """ - if self._prefer_native: - native_result = _native.DryRun(config_path) - return SyncResult.from_native(native_result) - options = SyncOptions(dry_run=True, compute_diff=True) - return self._cli_run_pipeline(config_path, options) + return self._cli_run_pipeline(config_path, options).to_dict() - def merge(self, config_path: str, dry_run: bool = False) -> SyncResult: + def merge(self, config_path: str, dry_run: bool = False) -> ExtendedDict: """Run only the merge phase of the pipeline. Args: @@ -447,20 +419,16 @@ def merge(self, config_path: str, dry_run: bool = False) -> SyncResult: dry_run: If True, don't make actual changes Returns: - SyncResult with merge operation details + Extended merge result payload. """ - if self._prefer_native: - native_result = _native.Merge(config_path, dry_run) - return SyncResult.from_native(native_result) - options = SyncOptions( operation=SyncOperation.MERGE, dry_run=dry_run, compute_diff=dry_run, ) - return self._cli_run_pipeline(config_path, options) + return self._cli_run_pipeline(config_path, options).to_dict() - def sync(self, config_path: str, dry_run: bool = False) -> SyncResult: + def sync(self, config_path: str, dry_run: bool = False) -> ExtendedDict: """Run only the sync phase of the pipeline. Args: @@ -468,50 +436,48 @@ def sync(self, config_path: str, dry_run: bool = False) -> SyncResult: dry_run: If True, don't make actual changes Returns: - SyncResult with sync operation details + Extended sync result payload. """ - if self._prefer_native: - native_result = _native.Sync(config_path, dry_run) - return SyncResult.from_native(native_result) - options = SyncOptions( operation=SyncOperation.SYNC, dry_run=dry_run, compute_diff=dry_run, ) - return self._cli_run_pipeline(config_path, options) + return self._cli_run_pipeline(config_path, options).to_dict() - def get_targets(self, config_path: str) -> tuple[list[str], str]: + def get_targets(self, config_path: str) -> ExtendedDict: """Get the list of targets from a configuration. Args: config_path: Path to YAML configuration file Returns: - Tuple of (targets, error_message) + Extended targets payload. """ - if self._prefer_native: - targets, err = _native.GetTargets(config_path) - return list(targets) if targets else [], err - info = self.get_config_info(config_path) - return info.targets, info.error_message - - def get_sources(self, config_path: str) -> tuple[list[str], str]: + targets = info.get("targets", []) + return extend_data({ + "targets": targets, + "count": len(targets), + "error_message": info.get("error_message", ""), + }) + + def get_sources(self, config_path: str) -> ExtendedDict: """Get the list of sources from a configuration. Args: config_path: Path to YAML configuration file Returns: - Tuple of (sources, error_message) + Extended sources payload. """ - if self._prefer_native: - sources, err = _native.GetSources(config_path) - return list(sources) if sources else [], err - info = self.get_config_info(config_path) - return info.sources, info.error_message + sources = info.get("sources", []) + return extend_data({ + "sources": sources, + "count": len(sources), + "error_message": info.get("error_message", ""), + }) # Import tools for AI framework integration diff --git a/src/extended_data/connectors/secrets/tools.py b/src/extended_data/connectors/secrets/tools.py index 6822524..8ce138e 100644 --- a/src/extended_data/connectors/secrets/tools.py +++ b/src/extended_data/connectors/secrets/tools.py @@ -6,10 +6,15 @@ from __future__ import annotations -from typing import Any +from collections.abc import Mapping +from typing import Any, cast from pydantic import BaseModel, Field +from extended_data.connectors.ai_tools import raise_unknown_tool_framework +from extended_data.containers import ExtendedDict, extend_data +from extended_data.primitives.redaction import redact_sensitive_data + # ============================================================================= # Input Schemas @@ -36,8 +41,8 @@ class RunPipelineSchema(BaseModel): description="Comma-separated list of targets to sync (empty for all)", ) continue_on_error: bool = Field( - False, - description="Continue processing if errors occur", + True, + description="Continue processing remaining targets after an error", ) @@ -52,7 +57,20 @@ class GetConfigInfoSchema(BaseModel): # ============================================================================= -def validate_config(config_path: str) -> dict[str, Any]: +def _redacted_extended_payload(value: Any) -> ExtendedDict: + """Promote a connector payload after redacting terminal-sensitive fields.""" + return cast(ExtendedDict, extend_data(redact_sensitive_data(value))) + + +def _redacted_mapping(value: Any) -> Mapping[str, Any]: + """Return a redacted mapping view for tool payload summaries.""" + redacted = redact_sensitive_data(value) + if isinstance(redacted, Mapping): + return redacted + return {} + + +def validate_config(config_path: str) -> ExtendedDict: """Validate a secrets sync pipeline configuration file. Args: @@ -64,13 +82,7 @@ def validate_config(config_path: str) -> dict[str, Any]: from extended_data.connectors.secrets import SecretsConnector connector = SecretsConnector() - is_valid, message = connector.validate_config(config_path) - - return { - "valid": is_valid, - "message": message, - "config_path": config_path, - } + return _redacted_extended_payload(connector.validate_config(config_path)) def run_pipeline( @@ -78,8 +90,8 @@ def run_pipeline( dry_run: bool = False, operation: str = "pipeline", targets: str | None = None, - continue_on_error: bool = False, -) -> dict[str, Any]: + continue_on_error: bool = True, +) -> ExtendedDict: """Run the secrets synchronization pipeline. This executes the two-phase pipeline (merge → sync) to synchronize @@ -124,23 +136,23 @@ def run_pipeline( compute_diff=dry_run, ) - result = connector.run_pipeline(config_path, options) - - return { - "success": result.success, - "target_count": result.target_count, - "secrets_processed": result.secrets_processed, - "secrets_added": result.secrets_added, - "secrets_modified": result.secrets_modified, - "secrets_removed": result.secrets_removed, - "secrets_unchanged": result.secrets_unchanged, - "duration_ms": result.duration_ms, - "error_message": result.error_message, - "diff_output": result.diff_output if dry_run else "", - } + result = _redacted_mapping(connector.run_pipeline(config_path, options)) + + return _redacted_extended_payload({ + "success": result.get("success", False), + "target_count": result.get("target_count", 0), + "secrets_processed": result.get("secrets_processed", 0), + "secrets_added": result.get("secrets_added", 0), + "secrets_modified": result.get("secrets_modified", 0), + "secrets_removed": result.get("secrets_removed", 0), + "secrets_unchanged": result.get("secrets_unchanged", 0), + "duration_ms": result.get("duration_ms", 0), + "error_message": result.get("error_message", ""), + "diff_output": result.get("diff_output", "") if dry_run else "", + }) -def dry_run(config_path: str) -> dict[str, Any]: +def dry_run(config_path: str) -> ExtendedDict: """Perform a dry run to see what changes would be made. Args: @@ -152,21 +164,21 @@ def dry_run(config_path: str) -> dict[str, Any]: from extended_data.connectors.secrets import SecretsConnector connector = SecretsConnector() - result = connector.dry_run(config_path) - - return { - "success": result.success, - "target_count": result.target_count, - "secrets_would_add": result.secrets_added, - "secrets_would_modify": result.secrets_modified, - "secrets_would_remove": result.secrets_removed, - "secrets_unchanged": result.secrets_unchanged, - "diff_output": result.diff_output, - "error_message": result.error_message, - } + result = _redacted_mapping(connector.dry_run(config_path)) + + return _redacted_extended_payload({ + "success": result.get("success", False), + "target_count": result.get("target_count", 0), + "secrets_would_add": result.get("secrets_added", 0), + "secrets_would_modify": result.get("secrets_modified", 0), + "secrets_would_remove": result.get("secrets_removed", 0), + "secrets_unchanged": result.get("secrets_unchanged", 0), + "diff_output": result.get("diff_output", ""), + "error_message": result.get("error_message", ""), + }) -def get_config_info(config_path: str) -> dict[str, Any]: +def get_config_info(config_path: str) -> ExtendedDict: """Get detailed information about a pipeline configuration. Args: @@ -178,22 +190,10 @@ def get_config_info(config_path: str) -> dict[str, Any]: from extended_data.connectors.secrets import SecretsConnector connector = SecretsConnector() - info = connector.get_config_info(config_path) - - return { - "valid": info.valid, - "error_message": info.error_message, - "source_count": info.source_count, - "target_count": info.target_count, - "sources": info.sources, - "targets": info.targets, - "has_merge_store": info.has_merge_store, - "vault_address": info.vault_address, - "aws_region": info.aws_region, - } + return _redacted_extended_payload(connector.get_config_info(config_path)) -def get_targets(config_path: str) -> dict[str, Any]: +def get_targets(config_path: str) -> ExtendedDict: """Get the list of sync targets from a configuration. Args: @@ -205,16 +205,10 @@ def get_targets(config_path: str) -> dict[str, Any]: from extended_data.connectors.secrets import SecretsConnector connector = SecretsConnector() - targets, error = connector.get_targets(config_path) - - return { - "targets": targets, - "count": len(targets), - "error_message": error, - } + return _redacted_extended_payload(connector.get_targets(config_path)) -def get_sources(config_path: str) -> dict[str, Any]: +def get_sources(config_path: str) -> ExtendedDict: """Get the list of secret sources from a configuration. Args: @@ -226,13 +220,7 @@ def get_sources(config_path: str) -> dict[str, Any]: from extended_data.connectors.secrets import SecretsConnector connector = SecretsConnector() - sources, error = connector.get_sources(config_path) - - return { - "sources": sources, - "count": len(sources), - "error_message": error, - } + return _redacted_extended_payload(connector.get_sources(config_path)) # ============================================================================= @@ -286,30 +274,16 @@ def get_sources(config_path: str) -> dict[str, Any]: def get_langchain_tools() -> list[Any]: """Get all secrets sync tools as LangChain StructuredTools.""" - try: - from langchain_core.tools import StructuredTool - except ImportError as e: - msg = "langchain-core is required for LangChain tools." - raise ImportError(msg) from e - - return [ - StructuredTool.from_function( - func=defn["func"], - name=defn["name"], - description=defn["description"], - args_schema=defn.get("schema"), - ) - for defn in TOOL_DEFINITIONS - ] + from extended_data.connectors.ai_tools import build_langchain_tools + + return build_langchain_tools(TOOL_DEFINITIONS) def get_crewai_tools() -> list[Any]: """Get all secrets sync tools as CrewAI tools.""" - try: - from crewai.tools import tool as crewai_tool - except ImportError as e: - msg = "crewai is required for CrewAI tools." - raise ImportError(msg) from e + from extended_data.connectors._optional import get_crewai_tool_decorator + + crewai_tool = get_crewai_tool_decorator() tools = [] for defn in TOOL_DEFINITIONS: @@ -332,7 +306,7 @@ def get_tools(framework: str = "auto") -> list[Any]: """Get secrets sync tools for the specified or auto-detected framework. Args: - framework: One of 'auto', 'langchain', 'crewai', 'strands', 'functions' + framework: One of 'auto', 'langchain', 'crewai', 'strands' Returns: List of tools in the appropriate format @@ -350,10 +324,10 @@ def get_tools(framework: str = "auto") -> list[Any]: return get_langchain_tools() if framework == "crewai": return get_crewai_tools() - if framework in ("strands", "functions"): + if framework == "strands": return get_strands_tools() - raise ValueError(f"Unknown framework: {framework}") + return raise_unknown_tool_framework(framework) # ============================================================================= diff --git a/src/extended_data/connectors/slack/__init__.py b/src/extended_data/connectors/slack/__init__.py index 746d78a..bc770cb 100644 --- a/src/extended_data/connectors/slack/__init__.py +++ b/src/extended_data/connectors/slack/__init__.py @@ -4,30 +4,57 @@ import sys -from collections.abc import Iterator, Mapping, Sequence +from collections.abc import Iterable, Iterator, Mapping, Sequence +from contextlib import suppress from time import sleep from typing import Any # batched was added in Python 3.12 if sys.version_info >= (3, 12): - from itertools import batched + from itertools import batched as _batched else: from itertools import islice - def batched(iterable, n: int) -> Iterator[tuple]: + def _batched(iterable: Iterable[Any], n: int) -> Iterator[tuple[Any, ...]]: """Batch an iterable into chunks of size n for Python < 3.12.""" it = iter(iterable) while batch := tuple(islice(it, n)): yield batch -from slack_sdk.errors import SlackApiError -from slack_sdk.web import WebClient - -from extended_data import is_nothing, wrap_raw_data_for_export -from extended_data.connectors.base import VendorConnectorBase +from extended_data.connectors._optional import require_extra +from extended_data.connectors.base import ConnectorBase +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString, extend_data, to_builtin +from extended_data.io import wrap_raw_data_for_export from extended_data.logging import Logging +from extended_data.primitives import is_nothing +from extended_data.primitives.redaction import redact_sensitive_data, redact_sensitive_text + + +class SlackFallbackError(Exception): + """Fallback exception used until slack-sdk is imported.""" + + +SlackApiError: Any = SlackFallbackError +WebClient: Any = None + + +def _load_slack_sdk() -> None: + """Load slack-sdk lazily so tool metadata can import without the slack extra.""" + global SlackApiError, WebClient + + if WebClient is None: + try: + if SlackApiError is SlackFallbackError: + SlackApiError = require_extra("slack_sdk.errors", "slack").SlackApiError + WebClient = require_extra("slack_sdk.web", "slack").WebClient + except ImportError as exc: + msg = "slack-sdk is required for SlackConnector. Install with: pip install extended-data[slack]" + raise ImportError(msg) from exc + elif SlackApiError is SlackFallbackError: + with suppress(ImportError): + SlackApiError = require_extra("slack_sdk.errors", "slack").SlackApiError # Settings @@ -37,37 +64,61 @@ def batched(iterable, n: int) -> Iterator[tuple]: class SlackAPIError(RuntimeError): """Slack API error wrapper.""" - def __init__(self, response): - self.response = response + def __init__(self, response: Any) -> None: + self.response = _slack_response_payload(response) self.status_code = response.status_code if hasattr(response, "status_code") else None - super().__init__(f"Slack API error: {response}") + super().__init__(f"Slack API error: {redact_sensitive_text(self.response)}") + +def _slack_response_payload(response: Any) -> dict[str, Any]: + """Normalize Slack SDK response objects into a serializable payload.""" + if isinstance(response, Mapping): + return redact_sensitive_data(dict(response)) -def get_divider() -> dict[str, str]: + data = getattr(response, "data", None) + if isinstance(data, Mapping): + return redact_sensitive_data(dict(data)) + + payload: dict[str, Any] = {} + response_get = getattr(response, "get", None) + if callable(response_get): + for key in ("ok", "error", "warning"): + value = response_get(key) + if value is not None: + payload[key] = value + + status_code = getattr(response, "status_code", None) + if status_code is not None: + payload["status_code"] = status_code + + return redact_sensitive_data(payload or {"response": str(response)}) + + +def get_divider() -> ExtendedDict: """Return a Slack divider block. Returns: - dict[str, str]: Slack block definition for a divider element. + Extended Slack block definition for a divider element. """ - return {"type": "divider"} + return extend_data({"type": "divider"}) -def get_header_block(field_title: str) -> list[dict[str, Any]]: +def get_header_block(field_title: str) -> ExtendedList[ExtendedDict]: """Return header and divider blocks for a section title. Args: field_title: Title text to render in the header block. Returns: - list[dict[str, Any]]: Header block followed by a divider. + Extended Slack blocks containing a header followed by a divider. """ - return [ + return extend_data([ {"type": "header", "text": {"type": "plain_text", "text": field_title}}, get_divider(), - ] + ]) -def get_field_context_message_blocks(field_name: str, context_data: Mapping) -> list[dict[str, Any]]: +def get_field_context_message_blocks(field_name: str, context_data: Mapping[str, Any]) -> ExtendedList[ExtendedDict]: """Build header and context blocks for detailed field data. Args: @@ -75,16 +126,16 @@ def get_field_context_message_blocks(field_name: str, context_data: Mapping) -> context_data: Mapping of key/value pairs rendered inside context blocks. Returns: - list[dict[str, Any]]: Blocks describing the field data. + Extended Slack blocks describing the field data. """ field_title = field_name.title() - blocks = [ + blocks: list[Any] = [ {"type": "header", "text": {"type": "plain_text", "text": field_title}}, get_divider(), ] - for field_keys in batched(context_data.keys(), 10): - context_elements = [] + for field_keys in _batched(context_data.keys(), 10): + context_elements: list[dict[str, str]] = [] for field_key in field_keys: field_value = context_data.get(field_key) if is_nothing(field_value): @@ -96,10 +147,10 @@ def get_field_context_message_blocks(field_name: str, context_data: Mapping) -> blocks.extend([{"type": "context", "elements": context_elements}, get_divider()]) - return blocks + return extend_data(blocks) -def get_key_value_blocks(k: str, v: Any) -> list[dict[str, Any]]: +def get_key_value_blocks(k: str, v: Any) -> ExtendedList[ExtendedDict]: """Format a key/value pair into Slack section blocks. Args: @@ -107,7 +158,7 @@ def get_key_value_blocks(k: str, v: Any) -> list[dict[str, Any]]: v: Value to render. Mappings are encoded to Slack-safe text. Returns: - list[dict[str, Any]]: Section block followed by a divider. + Extended Slack section block followed by a divider. """ k = k.title() if isinstance(v, Mapping): @@ -115,7 +166,7 @@ def get_key_value_blocks(k: str, v: Any) -> list[dict[str, Any]]: if not isinstance(v, str): v = str(v) - return [{"type": "section", "text": {"type": "mrkdwn", "text": f"*{k}*: {v}"}}, get_divider()] + return extend_data([{"type": "section", "text": {"type": "mrkdwn", "text": f"*{k}*: {v}"}}, get_divider()]) def get_rich_text_blocks( @@ -123,7 +174,7 @@ def get_rich_text_blocks( bold: bool = False, italic: bool = False, strike: bool = False, -) -> list[dict[str, Any]]: +) -> ExtendedList[ExtendedDict]: """Build a rich text block for multiline messages. Args: @@ -133,9 +184,9 @@ def get_rich_text_blocks( strike: Whether to strike through the text. Returns: - list[dict[str, Any]]: Rich-text block followed by a divider. + Extended rich-text block followed by a divider. """ - style = {} + style: dict[str, bool] = {} if bold: style["bold"] = True if italic: @@ -143,17 +194,17 @@ def get_rich_text_blocks( if strike: style["strike"] = True - elements = [] + elements: list[dict[str, Any]] = [] for line in lines: - element = {"type": "text", "text": line} + element: dict[str, Any] = {"type": "text", "text": line} if not is_nothing(style): element["style"] = style elements.append(element) - return [{"type": "rich_text", "elements": elements}, get_divider()] + return extend_data([{"type": "rich_text", "elements": elements}, get_divider()]) -class SlackConnector(VendorConnectorBase): +class SlackConnector(ConnectorBase): """Slack connector for messaging, directory, and channel management.""" def __init__( @@ -161,17 +212,18 @@ def __init__( token: str | None = None, bot_token: str | None = None, logger: Logging | None = None, - **kwargs, - ): + **kwargs: Any, + ) -> None: """Initialize the Slack connector. Args: token: Slack user token with directory scopes. bot_token: Bot token used for posting messages. logger: Optional shared logger instance. - **kwargs: Extra keyword arguments forwarded to VendorConnectorBase. + **kwargs: Extra keyword arguments forwarded to ConnectorBase. """ super().__init__(logger=logger, **kwargs) + _load_slack_sdk() self.token = token or self.get_input("SLACK_TOKEN", required=True) self.bot_token = bot_token or self.get_input("SLACK_BOT_TOKEN", required=True) @@ -191,7 +243,7 @@ def _normalize_identifier_filter( Returns: Optional[set[str]]: Unique identifier set, or None when not provided. """ - if is_nothing(identifiers): + if identifiers is None or is_nothing(identifiers): return None if isinstance(identifiers, str): @@ -206,14 +258,14 @@ def send_message( self, channel_name: str, text: str, - blocks: list | None = None, + blocks: list[Any] | ExtendedList[ExtendedDict] | None = None, lines: list[str] | None = None, bold: bool = False, italic: bool = False, strike: bool = False, thread_id: str | None = None, raise_on_api_error: bool = True, - ): + ) -> ExtendedString | ExtendedDict: """Send a message to a Slack channel using the bot token. Args: @@ -228,7 +280,8 @@ def send_message( raise_on_api_error: When True, raise `SlackAPIError` on API failures. Returns: - str | Any: Timestamp string for the posted message or the Slack API response. + Extended timestamp string, or an extended error payload when + `raise_on_api_error=False`. Raises: RuntimeError: If the bot is not a member of the channel. @@ -242,11 +295,13 @@ def send_message( channels = self.get_bot_channels() if channel_name not in channels: - raise RuntimeError(f"Bot not in channel {channel_name}. Add the bot first.") + safe_channel_name = redact_sensitive_text(channel_name, values=[channel_name]) + raise RuntimeError(f"Bot not in channel {safe_channel_name}. Add the bot first.") channel_id = channels[channel_name].get("id") if is_nothing(channel_id): - raise RuntimeError(f"{channel_name} does not have a channel ID") + safe_channel_name = redact_sensitive_text(channel_name, values=[channel_name]) + raise RuntimeError(f"{safe_channel_name} does not have a channel ID") opts: dict[str, Any] = {"channel": channel_id, "text": text} if not is_nothing(blocks): @@ -255,13 +310,13 @@ def send_message( opts["thread_ts"] = thread_id try: - return self.bot_web_client.chat_postMessage(**opts).get("ts") + return self.extend_result(self.bot_web_client.chat_postMessage(**to_builtin(opts)).get("ts")) except SlackApiError as exc: if raise_on_api_error: - raise SlackAPIError(exc.response) from exc - return exc.response + raise SlackAPIError(exc.response) from None + return self.extend_result(_slack_response_payload(exc.response)) - def get_bot_channels(self) -> dict[str, dict]: + def get_bot_channels(self) -> ExtendedDict: """Return channels the bot account is a member of. Returns: @@ -271,9 +326,10 @@ def get_bot_channels(self) -> dict[str, dict]: SlackAPIError: If Slack returns an error. """ try: - return {channel["name"]: channel for channel in self.bot_web_client.users_conversations()["channels"]} + channels = {channel["name"]: channel for channel in self.bot_web_client.users_conversations()["channels"]} + return self.extend_result(channels) except SlackApiError as exc: - raise SlackAPIError(exc.response) from exc + raise SlackAPIError(exc.response) from None def list_users( self, @@ -283,8 +339,8 @@ def list_users( include_deleted: bool | None = None, include_bots: bool | None = None, include_app_users: bool | None = None, - **kwargs, - ) -> dict[str, dict[str, Any]]: + **kwargs: Any, + ) -> ExtendedDict: """List Slack users with optional filtering flags. Args: @@ -318,7 +374,7 @@ def list_users( ) if include_deleted and include_bots and include_app_users: - return response + return self.extend_result(response) filtered = {} for user_id, user_data in response.items(): @@ -334,7 +390,7 @@ def list_users( continue filtered[user_id] = user_data - return filtered + return self.extend_result(filtered) def list_usergroups( self, @@ -343,8 +399,8 @@ def list_usergroups( include_users: bool | None = None, team_id: str | None = None, usergroup_ids: str | Sequence[str] | None = None, - **kwargs, - ) -> dict[str, dict[str, Any]]: + **kwargs: Any, + ) -> ExtendedDict: """List Slack user groups with optional filtering. Args: @@ -383,9 +439,9 @@ def list_usergroups( ) if not normalized_ids: - return response + return self.extend_result(response) - return {gid: gdata for gid, gdata in response.items() if gid in normalized_ids} + return self.extend_result({gid: gdata for gid, gdata in response.items() if gid in normalized_ids}) def list_conversations( self, @@ -395,8 +451,8 @@ def list_conversations( types: str | Sequence[str] | None = None, get_members: bool | None = None, channels_only: bool | None = None, - **kwargs, - ) -> dict[str, dict[str, Any]]: + **kwargs: Any, + ) -> ExtendedDict: """List Slack conversations with optional filtering. Args: @@ -442,16 +498,16 @@ def list_conversations( ) if not channels_only: - return response + return self.extend_result(response) - return {cid: cdata for cid, cdata in response.items() if cdata.get("is_channel")} + return self.extend_result({cid: cdata for cid, cdata in response.items() if cdata.get("is_channel")}) def _call_api( self, method: str, group_by: str | None = None, id_field_name: str = "id", - **kwargs, + **kwargs: Any, ) -> Any: """Call a Slack WebClient method with retry and grouping support. @@ -470,15 +526,16 @@ def _call_api( TimeoutError: If rate-limited retries exceed `MAX_RETRY_TIMEOUT_SECONDS`. """ call = getattr(self.web_client, method, None) + safe_method = redact_sensitive_text(method) if call is None: - raise AttributeError(f"{method} is not supported by the Slack WebClient") + raise AttributeError(f"{safe_method} is not supported by the Slack WebClient") - response = None + response: Any | None = None attempt = 1 total_delay = 0 while not response: - self.logger.debug(f"[Attempt {attempt}] Calling Slack WebClient {method}...") + self.logger.debug(f"[Attempt {attempt}] Calling Slack WebClient {safe_method}...") try: response = call(**kwargs) except SlackApiError as exc: @@ -486,21 +543,25 @@ def _call_api( delay = int(exc.response.headers["Retry-After"]) total_delay += delay if total_delay > MAX_RETRY_TIMEOUT_SECONDS: - raise TimeoutError(f"Slack WebClient {method} timed out after {total_delay} seconds") from exc + raise TimeoutError( + f"Slack WebClient {safe_method} timed out after {total_delay} seconds" + ) from None self.logger.warning(f"Rate limited. Retrying in {delay} seconds") sleep(delay) attempt += 1 else: - raise SlackAPIError(exc.response) from exc + raise SlackAPIError(exc.response) from None if is_nothing(response) or is_nothing(group_by): return response - grouped = {} + grouped: dict[str, dict[str, Any]] = {} for datum in response.get(group_by, {}): datum_id = datum.get(id_field_name) if is_nothing(datum_id): - raise RuntimeError(f"No ID for field {id_field_name} in returned datum: {datum}") + safe_field_name = redact_sensitive_text(id_field_name) + safe_datum = redact_sensitive_data(datum) + raise RuntimeError(f"No ID for field {safe_field_name} in returned datum: {safe_datum}") grouped[datum_id] = datum return grouped diff --git a/src/extended_data/connectors/slack/tools.py b/src/extended_data/connectors/slack/tools.py index 7ecaf5c..a3bbadd 100644 --- a/src/extended_data/connectors/slack/tools.py +++ b/src/extended_data/connectors/slack/tools.py @@ -25,10 +25,17 @@ import os -from typing import Any +from typing import TYPE_CHECKING, Any from pydantic import BaseModel, Field +from extended_data.connectors.ai_tools import raise_unknown_tool_framework +from extended_data.containers import ExtendedDict, ExtendedList, extend_data + + +if TYPE_CHECKING: + from extended_data.connectors.slack import SlackConnector + # ============================================================================= # Input Schemas @@ -71,7 +78,7 @@ class GetChannelHistorySchema(BaseModel): # ============================================================================= -def _get_connector(): +def _get_connector() -> SlackConnector: """Create a SlackConnector with tokens from environment variables. The slack_sdk WebClient only falls back to environment variables when @@ -98,7 +105,7 @@ def list_channels( exclude_archived: bool = True, channels_only: bool = True, limit: int = 100, -) -> list[dict[str, Any]]: +) -> ExtendedList[ExtendedDict]: """List Slack channels. Args: @@ -129,14 +136,14 @@ def list_channels( } ) - return result + return extend_data(result) def list_users( include_bots: bool = False, include_deleted: bool = False, max_results: int = 100, -) -> list[dict[str, Any]]: +) -> ExtendedList[ExtendedDict]: """List Slack users. Args: @@ -168,14 +175,14 @@ def list_users( } ) - return result + return extend_data(result) def send_message( channel: str, text: str, thread_id: str = "", -) -> dict[str, Any]: +) -> ExtendedDict: """Send a message to a Slack channel. Args: @@ -193,18 +200,20 @@ def send_message( thread_id=thread_id or None, ) - return { - "channel": channel, - "text": text, - "timestamp": timestamp, - "status": "sent", - } + return extend_data( + { + "channel": channel, + "text": text, + "timestamp": timestamp, + "status": "sent", + } + ) def get_channel_history( channel: str, limit: int = 100, -) -> list[dict[str, Any]]: +) -> ExtendedList[ExtendedDict]: """Get recent messages from a Slack channel. Args: @@ -225,7 +234,7 @@ def get_channel_history( break if not channel_id: - return [] + return extend_data([]) # Get conversation history using the internal _call_api method history = connector._call_api( @@ -248,7 +257,7 @@ def get_channel_history( } ) - return result + return extend_data(result) # ============================================================================= @@ -297,21 +306,9 @@ def get_langchain_tools() -> list[Any]: Raises: ImportError: If langchain-core is not installed. """ - try: - from langchain_core.tools import StructuredTool - except ImportError as e: - msg = "langchain-core is required for LangChain tools.\nInstall with: pip install extended-data[langchain]" - raise ImportError(msg) from e - - return [ - StructuredTool.from_function( - func=defn["func"], - name=defn["name"], - description=defn["description"], - args_schema=defn.get("schema") or defn.get("args_schema"), - ) - for defn in TOOL_DEFINITIONS - ] + from extended_data.connectors.ai_tools import build_langchain_tools + + return build_langchain_tools(TOOL_DEFINITIONS) def get_crewai_tools() -> list[Any]: @@ -323,11 +320,9 @@ def get_crewai_tools() -> list[Any]: Raises: ImportError: If crewai is not installed. """ - try: - from crewai.tools import tool as crewai_tool - except ImportError as e: - msg = "crewai is required for CrewAI tools.\nInstall with: pip install extended-data[crewai]" - raise ImportError(msg) from e + from extended_data.connectors._optional import get_crewai_tool_decorator + + crewai_tool = get_crewai_tool_decorator() tools = [] for defn in TOOL_DEFINITIONS: @@ -359,7 +354,6 @@ def get_tools(framework: str = "auto") -> list[Any]: - "langchain": Force LangChain StructuredTools - "crewai": Force CrewAI tools - "strands": Force plain functions for Strands - - "functions": Force plain functions (alias for strands) Returns: List of tools in the appropriate format for the framework. @@ -381,10 +375,10 @@ def get_tools(framework: str = "auto") -> list[Any]: return get_langchain_tools() if framework == "crewai": return get_crewai_tools() - if framework in ("strands", "functions"): + if framework == "strands": return get_strands_tools() - raise ValueError(f"Unknown framework: {framework}. Options: auto, langchain, crewai, strands, functions") + return raise_unknown_tool_framework(framework) # ============================================================================= diff --git a/src/extended_data/connectors/surface.py b/src/extended_data/connectors/surface.py new file mode 100644 index 0000000..a3f56fb --- /dev/null +++ b/src/extended_data/connectors/surface.py @@ -0,0 +1,63 @@ +"""Public connector data-surface helpers.""" + +from __future__ import annotations + +import builtins + +from collections.abc import Callable +from typing import Any, cast, get_args, get_origin, get_type_hints + +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedSet, ExtendedString, ExtendedTuple + + +EXTENDED_PAYLOAD_TYPES = (ExtendedDict, ExtendedList, ExtendedSet, ExtendedString, ExtendedTuple) + + +def connector_data_methods(connector_class: builtins.type[Any]) -> list[tuple[str, Callable[..., Any]]]: + """Return public connector methods that advertise Extended Data payloads.""" + methods: list[tuple[str, Callable[..., Any]]] = [] + for name in dir(connector_class): + if name.startswith("_"): + continue + attr = getattr(connector_class, name, None) + if is_connector_data_method(attr): + methods.append((name, cast(Callable[..., Any], attr))) + return methods + + +def is_connector_data_method(method: Any) -> bool: + """Return True when a callable belongs to the public data payload surface.""" + if not callable(method) or isinstance(method, builtins.type): + return False + + qualname = getattr(method, "__qualname__", "") + if qualname.startswith(("ConnectorBase.", "InputProvider.")): + return False + + return annotation_includes_extended_payload(return_annotation(method)) + + +def return_annotation(method: Callable[..., Any]) -> Any: + """Resolve a callable return annotation without failing on optional imports.""" + try: + return get_type_hints(method).get("return") + except Exception: + return getattr(method, "__annotations__", {}).get("return") + + +def annotation_includes_extended_payload(annotation: Any) -> bool: + """Return True when an annotation includes a Tier 2 container type.""" + if annotation is None: + return False + + if isinstance(annotation, str): + return any(payload_type.__name__ in annotation for payload_type in EXTENDED_PAYLOAD_TYPES) + + if annotation in EXTENDED_PAYLOAD_TYPES: + return True + + origin = get_origin(annotation) + if origin in EXTENDED_PAYLOAD_TYPES: + return True + + return any(annotation_includes_extended_payload(arg) for arg in get_args(annotation)) diff --git a/src/extended_data/connectors/vault/__init__.py b/src/extended_data/connectors/vault/__init__.py index 17a8a74..1cb2bdc 100644 --- a/src/extended_data/connectors/vault/__init__.py +++ b/src/extended_data/connectors/vault/__init__.py @@ -3,16 +3,37 @@ from __future__ import annotations from collections import deque +from collections.abc import Iterable, Mapping from datetime import datetime, timezone -from typing import Any +from typing import TYPE_CHECKING, Any -import hvac +from extended_data.connectors._optional import require_extra +from extended_data.connectors.base import ConnectorBase +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString +from extended_data.logging import Logging +from extended_data.primitives import is_nothing +from extended_data.primitives.redaction import redact_sensitive_text -from hvac.exceptions import VaultError -from extended_data import is_nothing -from extended_data.connectors.base import VendorConnectorBase -from extended_data.logging import Logging +if TYPE_CHECKING: + import hvac + + from hvac.exceptions import VaultError +else: + hvac = None + + class VaultError(Exception): + """Fallback exception used until hvac is imported.""" + + +def _load_hvac() -> Any: + """Load hvac lazily so tool metadata can import without the vault extra.""" + global VaultError, hvac + + if hvac is None: + hvac = require_extra("hvac", "vault") + VaultError = require_extra("hvac.exceptions", "vault").VaultError + return hvac # Default Vault settings @@ -23,7 +44,32 @@ VAULT_APPROLE_PATH_ENV_VAR = "VAULT_APPROLE_PATH" -class VaultConnector(VendorConnectorBase): +def _safe_log_text(value: Any, *sensitive_values: Any) -> str: + """Return a redacted string for Vault diagnostic output.""" + return redact_sensitive_text(value, values=_iter_diagnostic_values(sensitive_values)) + + +def _safe_ref_text(value: Any) -> str: + """Return a redacted string for sensitive Vault resource references.""" + return _safe_log_text(value, value) + + +def _iter_diagnostic_values(values: Iterable[Any]) -> Iterable[Any]: + """Yield scalar values from nested diagnostic context.""" + for value in values: + if value is None: + continue + if isinstance(value, Mapping): + yield from _iter_diagnostic_values(value.values()) + elif isinstance(value, (str, bytes)): + yield value + elif isinstance(value, Iterable): + yield from _iter_diagnostic_values(value) + else: + yield value + + +class VaultConnector(ConnectorBase): """Vault connector with token and AppRole authentication.""" def __init__( @@ -32,10 +78,11 @@ def __init__( vault_namespace: str | None = None, vault_token: str | None = None, logger: Logging | None = None, - **kwargs, - ): + **kwargs: Any, + ) -> None: super().__init__(logger=logger, **kwargs) + self._hvac = _load_hvac() self.vault_url = vault_url self.vault_namespace = vault_namespace self.vault_token = vault_token @@ -56,14 +103,14 @@ def vault_client(self) -> hvac.Client: vault_namespace = self.vault_namespace or self.get_input(VAULT_NAMESPACE_ENV_VAR, required=False) vault_token = self.vault_token or self.get_input("VAULT_TOKEN", required=False) - vault_opts: dict = {"url": vault_url} + vault_opts: dict[str, Any] = {"url": vault_url} if vault_namespace: vault_opts["namespace"] = vault_namespace if vault_token: vault_opts["token"] = vault_token try: - self._vault_client = hvac.Client(**vault_opts) + self._vault_client = self._hvac.Client(**vault_opts) if vault_token and self._vault_client.is_authenticated(): self._set_token_expiration() @@ -71,7 +118,10 @@ def vault_client(self) -> hvac.Client: return self._vault_client except VaultError as e: - self.logger.exception(f"Error initializing Vault client with token: {e}") + self.logger.error( # noqa: TRY400 - traceback can expose raw Vault credentials. + f"Error initializing Vault client with token: " + f"{_safe_log_text(e, vault_url, vault_namespace, vault_token)}" + ) # Fallback to AppRole authentication self.logger.info("Attempting AppRole authentication") @@ -86,7 +136,7 @@ def vault_client(self) -> hvac.Client: if vault_namespace: vault_opts["namespace"] = vault_namespace - self._vault_client = hvac.Client(**vault_opts) + self._vault_client = self._hvac.Client(**vault_opts) self._vault_client.auth.approle.login( role_id=role_id, secret_id=secret_id, @@ -100,13 +150,14 @@ def vault_client(self) -> hvac.Client: return self._vault_client except VaultError as e: - self.logger.exception(f"Error during AppRole authentication: {e}") - raise + msg = f"Error during AppRole authentication: {_safe_log_text(e, app_role_path, role_id, secret_id)}" + self.logger.error(msg) # noqa: TRY400 - traceback can expose raw Vault credentials. + raise RuntimeError(msg) from None msg = "Vault authentication failed: no valid token or AppRole credentials provided" raise RuntimeError(msg) - def _set_token_expiration(self): + def _set_token_expiration(self) -> None: """Set the token expiration time.""" if self._vault_client is None: return @@ -122,7 +173,7 @@ def _set_token_expiration(self): # No need to manually set tzinfo if running on Python 3.7 or newer. # If supporting Python <3.7, manual tzinfo assignment is required. except VaultError as e: - self.logger.exception(f"Failed to lookup Vault token expiration: {e}") + self.logger.warning(f"Failed to lookup Vault token expiration: {_safe_log_text(e)}") def _is_token_valid(self) -> bool: """Check if the current Vault token is still valid.""" @@ -143,7 +194,7 @@ def get_vault_client( vault_url: str | None = None, vault_namespace: str | None = None, vault_token: str | None = None, - **kwargs, + **kwargs: Any, ) -> hvac.Client: """Get an instance of the Vault client.""" instance = cls(vault_url, vault_namespace, vault_token, **kwargs) @@ -154,7 +205,7 @@ def list_secrets( root_path: str = "/", mount_point: str = "secret", max_depth: int | None = None, - ) -> dict[str, dict]: + ) -> ExtendedDict: """List secrets recursively from Vault KV v2 engine. Args: @@ -174,9 +225,9 @@ def list_secrets( raise ValueError(msg) display_root = root_path if root_path not in (None, "", "/") else "/" - self.logger.info(f"Listing Vault secrets from {mount_point}{display_root}") + self.logger.info(f"Listing Vault secrets from {_safe_ref_text(mount_point)}{_safe_ref_text(display_root)}") - secrets: dict[str, dict] = {} + secrets: dict[str, dict[str, Any]] = {} client = self.vault_client normalized_root = (root_path or "").strip("/") @@ -194,8 +245,8 @@ def list_secrets( for key in root_result.get("data", {}).get("keys", []) ] except VaultError as e: - self.logger.warning(f"Invalid root path {display_root}: {e}") - return secrets + self.logger.warning(f"Invalid root path {_safe_ref_text(display_root)}: {_safe_log_text(e, display_root)}") + return self.extend_result(secrets) stack: deque[tuple[str, int]] = deque(initial_paths) @@ -210,9 +261,11 @@ def list_secrets( mount_point=mount_point, )["data"]["data"] secrets[current_path] = secret_data - self.logger.debug(f"Retrieved secret: {current_path}") + self.logger.debug(f"Retrieved secret: {_safe_ref_text(current_path)}") except VaultError as e: - self.logger.warning(f"Failed to read secret {current_path}: {e}") + self.logger.warning( + f"Failed to read secret {_safe_ref_text(current_path)}: {_safe_log_text(e, current_path)}" + ) # It's a directory, list its contents if within max_depth elif max_depth is None or depth < max_depth: try: @@ -225,16 +278,18 @@ def list_secrets( new_path = f"{current_path}{key}" # current_path already ends with / stack.append((new_path, depth + 1)) except VaultError as e: - self.logger.warning(f"Failed to list path {current_path}: {e}") + self.logger.warning( + f"Failed to list path {_safe_ref_text(current_path)}: {_safe_log_text(e, current_path)}" + ) self.logger.info(f"Listed {len(secrets)} Vault secrets") - return secrets + return self.extend_result(secrets) def read_secret( self, path: str, mount_point: str = "secret", - ) -> dict | None: + ) -> ExtendedDict | None: """Read a single secret from Vault. Args: @@ -249,9 +304,12 @@ def read_secret( path=path, mount_point=mount_point, ) - return result.get("data", {}).get("data") + data = result.get("data", {}).get("data") + if data is None: + return None + return self.extend_result(data) except VaultError as e: - self.logger.warning(f"Failed to read secret {path}: {e}") + self.logger.warning(f"Failed to read secret {_safe_ref_text(path)}: {_safe_log_text(e, path)}") return None def get_secret( @@ -260,7 +318,7 @@ def get_secret( secret_name: str | None = None, matchers: dict[str, str] | None = None, mount_point: str = "secret", - ) -> dict | None: + ) -> ExtendedDict | None: """Get Vault secret by path, name, or by searching with matchers. This method supports three modes: @@ -277,7 +335,9 @@ def get_secret( Returns: Secret data dict, or None if not found. """ - self.logger.debug(f"Getting Vault secret: path={path}, secret_name={secret_name}") + self.logger.debug( + f"Getting Vault secret: path={_safe_ref_text(path)}, secret_name={_safe_ref_text(secret_name)}" + ) client = self.vault_client secret_data = None @@ -286,29 +346,29 @@ def get_secret( if not is_nothing(secret_name): # Build the full path: path/secret_name or just secret_name if path is "/" secret_path = f"{path}/{secret_name}" if path and path != "/" else secret_name - self.logger.debug(f"Resolved secret path: {secret_path}") + self.logger.debug(f"Resolved secret path: {_safe_ref_text(secret_path)}") try: secret_data = client.secrets.kv.v2.read_secret_version(path=secret_path, mount_point=mount_point)[ "data" ]["data"] - self.logger.debug(f"Retrieved secret data for {secret_path}") + self.logger.debug(f"Retrieved secret data for {_safe_ref_text(secret_path)}") except VaultError as e: self.logger.warning( - f"Failed to find secret at {path}" - + (f"/{secret_name}" if not is_nothing(secret_name) else "") - + f": {e}" + f"Failed to find secret at {_safe_ref_text(path)}" + + (f"/{_safe_ref_text(secret_name)}" if not is_nothing(secret_name) else "") + + f": {_safe_log_text(e, path, secret_name, secret_path)}" ) - return secret_data + return self.extend_result(secret_data) if secret_data is not None else None # No secret_name provided - search under path - self.logger.info(f"Finding secrets under {path}") + self.logger.info(f"Finding secrets under {_safe_ref_text(path)}") matching_secret_paths = self.list_secrets(root_path=path, mount_point=mount_point) self.logger.debug(f"Found {len(matching_secret_paths)} potential secrets") if is_nothing(matching_secret_paths): - self.logger.warning(f"No secrets found matching {path}") + self.logger.warning(f"No secrets found matching {_safe_ref_text(path)}") return None # Convert to deque for efficient popleft iteration @@ -316,19 +376,22 @@ def get_secret( while path_queue and secret_data is None: secret_path = path_queue.popleft() - self.logger.debug(f"Checking secret path: {secret_path}") + safe_secret_path = _safe_ref_text(secret_path) + self.logger.debug(f"Checking secret path: {safe_secret_path}") try: matching_secret_data = client.secrets.kv.v2.read_secret_version( path=secret_path, mount_point=mount_point )["data"]["data"] - self.logger.debug(f"Secret data for {secret_path}: {list(matching_secret_data.keys())}") + self.logger.debug( + f"Secret data for {safe_secret_path}: {_safe_log_text(list(matching_secret_data.keys()))}" + ) except VaultError: - self.logger.warning(f"{secret_path} is empty or invalid, skipping it") + self.logger.warning(f"{safe_secret_path} is empty or invalid, skipping it") continue # If no matchers, take the first non-empty secret - if is_nothing(matchers): + if matchers is None or is_nothing(matchers): self.logger.warning("No matchers provided, taking the first non-empty secret found") secret_data = matching_secret_data continue @@ -338,19 +401,19 @@ def get_secret( for k, v in matchers.items(): datum = matching_secret_data.get(k) if datum == v: - self.logger.info(f"Matching {secret_path} on matcher {k}: {datum} equals {v}") + self.logger.info(f"Matched {safe_secret_path} on matcher {_safe_log_text(k)}") found_match = True break if found_match: secret_data = matching_secret_data - return secret_data + return self.extend_result(secret_data) if secret_data is not None else None def write_secret( self, path: str, - data: dict, + data: dict[str, Any], mount_point: str = "secret", ) -> bool: """Write a secret to Vault. @@ -369,26 +432,28 @@ def write_secret( secret=data, mount_point=mount_point, ) - self.logger.info(f"Wrote secret to {path}") + self.logger.info(f"Wrote secret to {_safe_ref_text(path)}") return True except VaultError as e: - self.logger.exception(f"Failed to write secret {path}: {e}") + self.logger.error( # noqa: TRY400 - traceback can expose raw Vault secret paths. + f"Failed to write secret {_safe_ref_text(path)}: {_safe_log_text(e, path, data)}" + ) return False # --------------------------------------------------------------------- - # Vault AWS IAM helpers (migrated from terraform-modules) + # Vault AWS IAM helpers # --------------------------------------------------------------------- def list_aws_iam_roles( self, mount_point: str = "aws", - name_prefix: str | None = None, - ) -> list[str]: + prefix: str | None = None, + ) -> ExtendedList[ExtendedString]: """List AWS IAM roles configured in Vault's AWS secrets engine. Args: mount_point: AWS secrets engine mount point (default: "aws"). - name_prefix: Optional prefix filter for role names. + prefix: Optional prefix filter for role names. Returns: List of role names available for credential generation. @@ -401,21 +466,24 @@ def list_aws_iam_roles( try: response = aws_secrets.list_roles(mount_point=mount_point) except VaultError as e: - self.logger.warning(f"Failed to list AWS IAM roles from mount {mount_point}: {e}") - return [] + self.logger.warning( + f"Failed to list AWS IAM roles from mount {_safe_ref_text(mount_point)}: " + f"{_safe_log_text(e, mount_point)}" + ) + return self.extend_result([]) role_names = response.get("data", {}).get("keys", []) or [] - if name_prefix: - role_names = [role for role in role_names if role.startswith(name_prefix)] + if prefix: + role_names = [role for role in role_names if role.startswith(prefix)] - self.logger.info(f"Found {len(role_names)} AWS IAM roles under mount {mount_point}") - return role_names + self.logger.info(f"Found {len(role_names)} AWS IAM roles under mount {_safe_ref_text(mount_point)}") + return self.extend_result(role_names) def get_aws_iam_role( self, role_name: str, mount_point: str = "aws", - ) -> dict | None: + ) -> ExtendedDict | None: """Retrieve details about a specific AWS IAM role configured in Vault. Args: @@ -434,15 +502,15 @@ def get_aws_iam_role( try: response = self.vault_client.secrets.aws.read_role(name=role_name, mount_point=mount_point) except VaultError as e: - self.logger.warning(f"Failed to read AWS IAM role {role_name}: {e}") + self.logger.warning(f"Failed to read AWS IAM role {_safe_ref_text(role_name)}: {_safe_log_text(e, role_name)}") return None role_data = response.get("data") if is_nothing(role_data): - self.logger.warning(f"AWS IAM role {role_name} exists but returned no data") + self.logger.warning(f"AWS IAM role {_safe_ref_text(role_name)} exists but returned no data") return None - return role_data + return self.extend_result(role_data) def generate_aws_credentials( self, @@ -450,7 +518,7 @@ def generate_aws_credentials( mount_point: str = "aws", ttl: str | None = None, credential_type: str | None = None, - ) -> dict[str, Any]: + ) -> ExtendedDict: """Generate AWS credentials via Vault's AWS secrets engine. Args: @@ -482,15 +550,19 @@ def generate_aws_credentials( try: response = aws_secrets.generate_credentials(name=role_name, mount_point=mount_point, **generate_kwargs) except VaultError as e: - self.logger.exception(f"Failed to generate AWS credentials for role {role_name}: {e}") - raise RuntimeError(f"Failed to generate AWS credentials for role {role_name}") from e + safe_role_name = _safe_ref_text(role_name) + self.logger.error( # noqa: TRY400 - traceback can expose raw Vault role names. + f"Failed to generate AWS credentials for role {safe_role_name}: " + f"{_safe_log_text(e, role_name, mount_point, generate_kwargs)}" + ) + raise RuntimeError(f"Failed to generate AWS credentials for role {safe_role_name}") from None credentials = response.get("data") or {} if not credentials: - raise RuntimeError(f"Vault returned empty credentials for role {role_name}") + raise RuntimeError(f"Vault returned empty credentials for role {_safe_ref_text(role_name)}") - self.logger.info(f"Generated AWS credentials for role {role_name}") - return credentials + self.logger.info(f"Generated AWS credentials for role {_safe_ref_text(role_name)}") + return self.extend_result(credentials) from extended_data.connectors.vault.tools import ( diff --git a/src/extended_data/connectors/vault/tools.py b/src/extended_data/connectors/vault/tools.py index d30f3a6..d6ec738 100644 --- a/src/extended_data/connectors/vault/tools.py +++ b/src/extended_data/connectors/vault/tools.py @@ -6,10 +6,14 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any from pydantic import BaseModel, Field +from extended_data.connectors.ai_tools import raise_unknown_tool_framework +from extended_data.containers import ExtendedDict, ExtendedList, extend_data + # ============================================================================= # Input Schemas @@ -40,7 +44,7 @@ def list_secrets( root_path: str = "/", mount_point: str = "secret", max_depth: int | None = 10, -) -> list[dict[str, Any]]: +) -> ExtendedList[ExtendedDict]: """List secrets recursively from Vault KV v2 engine. Args: @@ -63,16 +67,16 @@ def list_secrets( "path": path, "mount_point": mount_point, "data": data, - "key_count": len(data) if isinstance(data, dict) else 0, + "key_count": len(data) if isinstance(data, Mapping) else 0, } ) - return result + return extend_data(result) def read_secret( path: str, mount_point: str = "secret", -) -> dict[str, Any]: +) -> ExtendedDict: """Read a single secret from Vault. Args: @@ -87,12 +91,14 @@ def read_secret( connector = VaultConnector() data = connector.read_secret(path=path, mount_point=mount_point) - return { - "path": path, - "mount_point": mount_point, - "data": data or {}, - "found": data is not None, - } + return extend_data( + { + "path": path, + "mount_point": mount_point, + "data": data or {}, + "found": data is not None, + } + ) # ============================================================================= @@ -122,30 +128,16 @@ def read_secret( def get_langchain_tools() -> list[Any]: """Get all Vault tools as LangChain StructuredTools.""" - try: - from langchain_core.tools import StructuredTool - except ImportError as e: - msg = "langchain-core is required for LangChain tools." - raise ImportError(msg) from e - - return [ - StructuredTool.from_function( - func=defn["func"], - name=defn["name"], - description=defn["description"], - args_schema=defn.get("schema") or defn.get("args_schema"), - ) - for defn in TOOL_DEFINITIONS - ] + from extended_data.connectors.ai_tools import build_langchain_tools + + return build_langchain_tools(TOOL_DEFINITIONS) def get_crewai_tools() -> list[Any]: """Get all Vault tools as CrewAI tools.""" - try: - from crewai.tools import tool as crewai_tool - except ImportError as e: - msg = "crewai is required for CrewAI tools." - raise ImportError(msg) from e + from extended_data.connectors._optional import get_crewai_tool_decorator + + crewai_tool = get_crewai_tool_decorator() tools = [] for defn in TOOL_DEFINITIONS: @@ -179,10 +171,10 @@ def get_tools(framework: str = "auto") -> list[Any]: return get_langchain_tools() if framework == "crewai": return get_crewai_tools() - if framework in ("strands", "functions"): + if framework == "strands": return get_strands_tools() - raise ValueError(f"Unknown framework: {framework}") + return raise_unknown_tool_framework(framework) # ============================================================================= diff --git a/src/extended_data/connectors/zoom/__init__.py b/src/extended_data/connectors/zoom/__init__.py index ed0ed1e..aa46e2a 100644 --- a/src/extended_data/connectors/zoom/__init__.py +++ b/src/extended_data/connectors/zoom/__init__.py @@ -4,19 +4,53 @@ import base64 +from collections.abc import Iterable, Mapping from typing import Any import requests -from extended_data.connectors.base import VendorConnectorBase +from extended_data.connectors.base import ConnectorBase +from extended_data.containers import ExtendedDict, ExtendedList, to_builtin +from extended_data.io.files import decode_file from extended_data.logging import Logging +from extended_data.primitives.redaction import redact_sensitive_text # Default timeout for HTTP requests in seconds DEFAULT_REQUEST_TIMEOUT = 30 -class ZoomConnector(VendorConnectorBase): +def _safe_zoom_text(value: Any, *sensitive_values: Any) -> str: + """Redact secrets and request identifiers from Zoom diagnostics.""" + return redact_sensitive_text(value, values=_iter_diagnostic_values(sensitive_values)) + + +def _iter_diagnostic_values(values: Iterable[Any]) -> Iterable[Any]: + """Yield scalar values from nested diagnostic context.""" + for value in values: + if value is None: + continue + if isinstance(value, Mapping): + yield from _iter_diagnostic_values(value.values()) + elif isinstance(value, (str, bytes)): + yield value + elif isinstance(value, Iterable): + yield from _iter_diagnostic_values(value) + else: + yield value + + +def _zoom_error(action: str, exc: BaseException, *sensitive_values: Any) -> str: + """Build a redacted Zoom operational error message.""" + return f"{action}: {_safe_zoom_text(exc, *sensitive_values)}" + + +def _zoom_response_error(action: str, data: Any, *sensitive_values: Any) -> RuntimeError: + """Build a redacted malformed-response error.""" + return RuntimeError(f"{action}: {_safe_zoom_text(data, *sensitive_values)}") + + +class ZoomConnector(ConnectorBase): """Zoom connector for user management.""" def __init__( @@ -25,8 +59,8 @@ def __init__( client_secret: str | None = None, account_id: str | None = None, logger: Logging | None = None, - **kwargs, - ): + **kwargs: Any, + ) -> None: super().__init__(logger=logger, **kwargs) self.errors: list[str] = [] # Track errors for programmatic access @@ -34,6 +68,37 @@ def __init__( self.client_secret = client_secret or self.get_input("ZOOM_CLIENT_SECRET", required=True) self.account_id = account_id or self.get_input("ZOOM_ACCOUNT_ID", required=True) + def _response_json(self, response: Any, action: str, *sensitive_values: Any) -> Any: + """Parse a Zoom JSON response or raise a redacted diagnostic.""" + content = getattr(response, "content", b"") + if not content: + return {} + try: + return decode_file(content, suffix="json", as_extended=True) + except Exception: + raise _zoom_response_error(action, getattr(response, "text", content), *sensitive_values) from None + + def _response_mapping(self, response: Any, action: str, *sensitive_values: Any) -> dict[str, Any]: + """Parse and validate a Zoom object response.""" + data = self._response_json(response, action, *sensitive_values) + if not isinstance(data, Mapping): + raise _zoom_response_error(action, data, *sensitive_values) + return to_builtin(data) + + def _response_list_field( + self, + response: Any, + field_name: str, + action: str, + *sensitive_values: Any, + ) -> list[dict[str, Any]]: + """Parse and validate a Zoom list field containing object payloads.""" + data = self._response_mapping(response, action, *sensitive_values) + items = data.get(field_name, []) + if not isinstance(items, list) or any(not isinstance(item, Mapping) for item in items): + raise _zoom_response_error(action, data, *sensitive_values) + return [dict(item) for item in items] + def get_access_token(self) -> str | None: """Get an OAuth access token from Zoom.""" url = "https://zoom.us/oauth/token" @@ -47,10 +112,32 @@ def get_access_token(self) -> str | None: try: response = requests.post(url, headers=headers, data=data, timeout=DEFAULT_REQUEST_TIMEOUT) response.raise_for_status() - return response.json().get("access_token") + token_data = self._response_mapping( + response, + "Unexpected Zoom access token response", + self.client_id, + self.client_secret, + self.account_id, + ) + token = token_data.get("access_token") + if not isinstance(token, str) or not token.strip(): + raise _zoom_response_error( + "Unexpected Zoom access token response", + token_data, + self.client_id, + self.client_secret, + self.account_id, + ) + return token except requests.exceptions.RequestException as exc: - msg = "Failed to get Zoom access token" - raise RuntimeError(msg) from exc + msg = _zoom_error( + "Failed to get Zoom access token", + exc, + self.client_id, + self.client_secret, + self.account_id, + ) + raise RuntimeError(msg) from None def get_headers(self) -> dict[str, str]: """Get headers with authorization for Zoom API calls.""" @@ -60,8 +147,12 @@ def get_headers(self) -> dict[str, str]: raise RuntimeError(msg) return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} - def get_zoom_users(self) -> dict[str, dict[str, Any]]: - """Get all Zoom users.""" + def list_users(self) -> ExtendedDict: + """List all Zoom users. + + Returns: + Dictionary mapping user emails to user data. + """ url = "https://api.zoom.us/v2/users" headers = self.get_headers() users: dict[str, dict[str, Any]] = {} @@ -76,17 +167,24 @@ def get_zoom_users(self) -> dict[str, dict[str, Any]]: try: response = requests.get(url, headers=headers, params=params, timeout=DEFAULT_REQUEST_TIMEOUT) response.raise_for_status() - data = response.json() - for user in data.get("users", []): - users[user["email"]] = user + data = self._response_mapping(response, "Unexpected Zoom users response", next_page_token, params) + raw_users = data.get("users", []) + if not isinstance(raw_users, list): + raise _zoom_response_error("Unexpected Zoom users response", data, next_page_token, params) + for user in raw_users: + if not isinstance(user, Mapping) or not isinstance(user.get("email"), str): + raise _zoom_response_error("Unexpected Zoom users response", data, next_page_token, params) + users[user["email"]] = dict(user) next_page_token = data.get("next_page_token") + if next_page_token is not None and not isinstance(next_page_token, str): + raise _zoom_response_error("Unexpected Zoom users response", data, next_page_token, params) if not next_page_token: break except requests.exceptions.RequestException as exc: - raise RuntimeError(f"Failed to get Zoom users: {exc}") from exc + raise RuntimeError(_zoom_error("Failed to get Zoom users", exc, next_page_token, params)) from None - return users + return self.extend_result(users) def remove_zoom_user(self, email: str) -> None: """Remove a Zoom user.""" @@ -95,11 +193,11 @@ def remove_zoom_user(self, email: str) -> None: try: response = requests.delete(url, headers=headers, timeout=DEFAULT_REQUEST_TIMEOUT) response.raise_for_status() - self.logger.warning(f"Removed Zoom user {email}") + self.logger.warning("Removed Zoom user") except requests.exceptions.RequestException as exc: - error_msg = f"Failed to remove Zoom user {email}: {exc}" + error_msg = _zoom_error("Failed to remove Zoom user", exc, email) self.errors.append(error_msg) - self.logger.exception(error_msg) + self.logger.error(error_msg) # noqa: TRY400 - traceback can expose raw Zoom user identifiers. def create_zoom_user(self, email: str, first_name: str, last_name: str) -> bool: """Create a Zoom user with a paid license.""" @@ -112,25 +210,15 @@ def create_zoom_user(self, email: str, first_name: str, last_name: str) -> bool: try: response = requests.post(url, headers=headers, json=user_info, timeout=DEFAULT_REQUEST_TIMEOUT) response.raise_for_status() - self.logger.info(f"Created Zoom user {email}") + self.logger.info("Created Zoom user") return True except requests.exceptions.RequestException as exc: - error_msg = f"Failed to create Zoom user {email}: {exc}" + error_msg = _zoom_error("Failed to create Zoom user", exc, email, first_name, last_name) self.errors.append(error_msg) - self.logger.exception(error_msg) + self.logger.error(error_msg) # noqa: TRY400 - traceback can expose raw Zoom user identifiers. return False - def list_users(self) -> dict[str, dict[str, Any]]: - """List all Zoom users. - - This is an alias for get_zoom_users() for consistency with AI tools naming. - - Returns: - Dictionary mapping user emails to user data - """ - return self.get_zoom_users() - - def get_user(self, user_id: str) -> dict[str, Any]: + def get_user(self, user_id: str) -> ExtendedDict: """Get a specific Zoom user by ID or email. Args: @@ -145,11 +233,11 @@ def get_user(self, user_id: str) -> dict[str, Any]: try: response = requests.get(url, headers=headers, timeout=DEFAULT_REQUEST_TIMEOUT) response.raise_for_status() - return response.json() + return self.extend_result(self._response_mapping(response, "Unexpected Zoom user response", user_id)) except requests.exceptions.RequestException as exc: - raise RuntimeError(f"Failed to get Zoom user {user_id}: {exc}") from exc + raise RuntimeError(_zoom_error("Failed to get Zoom user", exc, user_id)) from None - def list_meetings(self, user_id: str, meeting_type: str = "scheduled") -> list[dict[str, Any]]: + def list_meetings(self, user_id: str, meeting_type: str = "scheduled") -> ExtendedList[ExtendedDict]: """List meetings for a specific user. Args: @@ -166,12 +254,13 @@ def list_meetings(self, user_id: str, meeting_type: str = "scheduled") -> list[d try: response = requests.get(url, headers=headers, params=params, timeout=DEFAULT_REQUEST_TIMEOUT) response.raise_for_status() - data = response.json() - return data.get("meetings", []) + return self.extend_result( + self._response_list_field(response, "meetings", "Unexpected Zoom meetings response", user_id, params) + ) except requests.exceptions.RequestException as exc: - raise RuntimeError(f"Failed to list meetings for user {user_id}: {exc}") from exc + raise RuntimeError(_zoom_error("Failed to list Zoom meetings", exc, user_id, params)) from None - def get_meeting(self, meeting_id: str) -> dict[str, Any]: + def get_meeting(self, meeting_id: str) -> ExtendedDict: """Get details of a specific meeting. Args: @@ -186,9 +275,9 @@ def get_meeting(self, meeting_id: str) -> dict[str, Any]: try: response = requests.get(url, headers=headers, timeout=DEFAULT_REQUEST_TIMEOUT) response.raise_for_status() - return response.json() + return self.extend_result(self._response_mapping(response, "Unexpected Zoom meeting response", meeting_id)) except requests.exceptions.RequestException as exc: - raise RuntimeError(f"Failed to get meeting {meeting_id}: {exc}") from exc + raise RuntimeError(_zoom_error("Failed to get Zoom meeting", exc, meeting_id)) from None from extended_data.connectors.zoom.tools import ( diff --git a/src/extended_data/connectors/zoom/tools.py b/src/extended_data/connectors/zoom/tools.py index d7914c7..a790c47 100644 --- a/src/extended_data/connectors/zoom/tools.py +++ b/src/extended_data/connectors/zoom/tools.py @@ -10,6 +10,9 @@ from pydantic import BaseModel, Field +from extended_data.connectors.ai_tools import raise_unknown_tool_framework +from extended_data.containers import ExtendedDict, ExtendedList, extend_data + # ============================================================================= # Input Schemas @@ -49,7 +52,7 @@ class GetMeetingSchema(BaseModel): # ============================================================================= -def list_users(max_results: int = 100) -> list[dict[str, Any]]: +def list_users(max_results: int = 100) -> ExtendedList[ExtendedDict]: """List Zoom users. Args: @@ -64,10 +67,10 @@ def list_users(max_results: int = 100) -> list[dict[str, Any]]: users = connector.list_users() # Sort by email for consistent output in tests sorted_users = [users[email] for email in sorted(users.keys())] - return sorted_users[:max_results] + return extend_data(sorted_users[:max_results]) -def get_user(user_id: str) -> dict[str, Any]: +def get_user(user_id: str) -> ExtendedDict: """Get a specific Zoom user by ID or email. Args: @@ -79,14 +82,14 @@ def get_user(user_id: str) -> dict[str, Any]: from extended_data.connectors.zoom import ZoomConnector connector = ZoomConnector() - return connector.get_user(user_id) + return extend_data(connector.get_user(user_id)) def list_meetings( user_id: str, meeting_type: str = "scheduled", max_results: int = 100, -) -> list[dict[str, Any]]: +) -> ExtendedList[ExtendedDict]: """List Zoom meetings for a specific user. Args: @@ -101,10 +104,10 @@ def list_meetings( connector = ZoomConnector() meetings = connector.list_meetings(user_id, meeting_type) - return meetings[:max_results] + return extend_data(meetings[:max_results]) -def get_meeting(meeting_id: str) -> dict[str, Any]: +def get_meeting(meeting_id: str) -> ExtendedDict: """Get details of a specific Zoom meeting. Args: @@ -116,7 +119,7 @@ def get_meeting(meeting_id: str) -> dict[str, Any]: from extended_data.connectors.zoom import ZoomConnector connector = ZoomConnector() - return connector.get_meeting(meeting_id) + return extend_data(connector.get_meeting(meeting_id)) # ============================================================================= @@ -158,30 +161,16 @@ def get_meeting(meeting_id: str) -> dict[str, Any]: def get_langchain_tools() -> list[Any]: """Get all Zoom tools as LangChain StructuredTools.""" - try: - from langchain_core.tools import StructuredTool - except ImportError as e: - msg = "langchain-core is required for LangChain tools." - raise ImportError(msg) from e - - return [ - StructuredTool.from_function( - func=defn["func"], - name=defn["name"], - description=defn["description"], - args_schema=defn.get("schema") or defn.get("args_schema"), - ) - for defn in TOOL_DEFINITIONS - ] + from extended_data.connectors.ai_tools import build_langchain_tools + + return build_langchain_tools(TOOL_DEFINITIONS) def get_crewai_tools() -> list[Any]: """Get all Zoom tools as CrewAI tools.""" - try: - from crewai.tools import tool as crewai_tool - except ImportError as e: - msg = "crewai is required for CrewAI tools." - raise ImportError(msg) from e + from extended_data.connectors._optional import get_crewai_tool_decorator + + crewai_tool = get_crewai_tool_decorator() tools = [] for defn in TOOL_DEFINITIONS: @@ -215,10 +204,10 @@ def get_tools(framework: str = "auto") -> list[Any]: return get_langchain_tools() if framework == "crewai": return get_crewai_tools() - if framework in ("strands", "functions"): + if framework == "strands": return get_strands_tools() - raise ValueError(f"Unknown framework: {framework}") + return raise_unknown_tool_framework(framework) # ============================================================================= diff --git a/src/extended_data/containers/__init__.py b/src/extended_data/containers/__init__.py new file mode 100644 index 0000000..0b59f26 --- /dev/null +++ b/src/extended_data/containers/__init__.py @@ -0,0 +1,17 @@ +"""Tier 2 extended container classes.""" + +from extended_data.containers.factory import extend_data, to_builtin +from extended_data.containers.mappings import ExtendedDict +from extended_data.containers.sequences import ExtendedList, ExtendedSet, ExtendedTuple +from extended_data.containers.strings import ExtendedString + + +__all__ = [ + "ExtendedDict", + "ExtendedList", + "ExtendedSet", + "ExtendedString", + "ExtendedTuple", + "extend_data", + "to_builtin", +] diff --git a/src/extended_data/containers/factory.py b/src/extended_data/containers/factory.py new file mode 100644 index 0000000..6a881a5 --- /dev/null +++ b/src/extended_data/containers/factory.py @@ -0,0 +1,57 @@ +"""Factories for moving between plain data and extended containers.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from extended_data.containers.mappings import ExtendedDict +from extended_data.containers.sequences import ExtendedList, ExtendedSet, ExtendedTuple +from extended_data.containers.strings import ExtendedString +from extended_data.primitives.formats.yaml import LiteralScalarString, YamlPairs, YamlTagged + + +def extend_data(value: Any) -> Any: + """Recursively wrap built-in containers in Extended Data containers.""" + if isinstance(value, YamlTagged | YamlPairs | LiteralScalarString): + return value + if isinstance(value, ExtendedString | ExtendedDict | ExtendedList | ExtendedSet | ExtendedTuple): + return value + if isinstance(value, str): + return ExtendedString(value) + if isinstance(value, Mapping): + return ExtendedDict({key: extend_data(item) for key, item in value.items()}) + if isinstance(value, list): + return ExtendedList(extend_data(item) for item in value) + if isinstance(value, tuple): + return ExtendedTuple(extend_data(item) for item in value) + if isinstance(value, set | frozenset): + return ExtendedSet(extend_data(item) for item in value) + return value + + +def to_builtin(value: Any) -> Any: + """Recursively unwrap Extended Data containers to built-in Python values.""" + if isinstance(value, YamlTagged | YamlPairs | LiteralScalarString): + return value + if isinstance(value, ExtendedString): + return str(value) + if isinstance(value, ExtendedDict): + return {to_builtin(key): to_builtin(item) for key, item in value.items()} + if isinstance(value, ExtendedList): + return [to_builtin(item) for item in value] + if isinstance(value, ExtendedTuple): + return tuple(to_builtin(item) for item in value) + if isinstance(value, ExtendedSet): + return {to_builtin(item) for item in value} + if isinstance(value, Mapping): + return {to_builtin(key): to_builtin(item) for key, item in value.items()} + if isinstance(value, list): + return [to_builtin(item) for item in value] + if isinstance(value, tuple): + return tuple(to_builtin(item) for item in value) + if isinstance(value, set): + return {to_builtin(item) for item in value} + if isinstance(value, frozenset): + return frozenset(to_builtin(item) for item in value) + return value diff --git a/src/extended_data/containers/mappings.py b/src/extended_data/containers/mappings.py new file mode 100644 index 0000000..2b4f02a --- /dev/null +++ b/src/extended_data/containers/mappings.py @@ -0,0 +1,183 @@ +"""Extended mapping container built on Tier 1 primitives.""" + +from __future__ import annotations + +from collections import UserDict +from collections.abc import Iterable, Mapping +from typing import TYPE_CHECKING, Any, overload + +from typing_extensions import Self + + +if TYPE_CHECKING: + from _typeshed import SupportsKeysAndGetItem + + from extended_data.containers.sequences import ExtendedList, ExtendedTuple + +from extended_data.primitives.mappings import ( + all_values_from_map, + deduplicate_map, + deep_merge, + filter_map, + first_non_empty_value_from_map, + flatten_map, + unhump_map, +) +from extended_data.primitives.splitting import split_dict_by_type +from extended_data.primitives.state import all_non_empty_in_dict, any_non_empty, yield_non_empty +from extended_data.primitives.types import reconstruct_special_types + + +class ExtendedDict(UserDict[str, Any]): + """Dictionary wrapper with chainable primitive operations.""" + + def __init__(self, initialdata: Mapping[str, Any] | None = None, **kwargs: Any) -> None: + """Initialize the extended dictionary.""" + super().__init__() + self.update(initialdata or {}, **kwargs) + + def __setitem__(self, key: str, item: Any) -> None: + """Set a value while preserving extended nested containers.""" + from extended_data.containers.factory import extend_data + + self.data[key] = extend_data(item) + + @overload + def update(self, other: SupportsKeysAndGetItem[str, Any], /) -> None: ... + + @overload + def update(self, other: SupportsKeysAndGetItem[str, Any], /, **kwargs: Any) -> None: ... + + @overload + def update(self, other: Iterable[tuple[str, Any]], /) -> None: ... + + @overload + def update(self, other: Iterable[tuple[str, Any]], /, **kwargs: Any) -> None: ... + + @overload + def update(self, **kwargs: Any) -> None: ... + + def update(self, *args: Any, **kwargs: Any) -> None: # type: ignore[misc] + """Update values while preserving extended nested containers.""" + if len(args) > 1: + msg = f"update expected at most 1 argument, got {len(args)}" + raise TypeError(msg) + + if args: + other = args[0] + if hasattr(other, "items"): + for key, value in other.items(): + self[key] = value + elif hasattr(other, "keys") and hasattr(other, "__getitem__"): + keys = other.keys() + for key in keys: + self[key] = other[key] + else: + for key, value in other: + self[key] = value + + for key, value in kwargs.items(): + self[key] = value + + def setdefault(self, key: str, default: Any = None) -> Any: + """Insert a default while returning the promoted stored value.""" + if key not in self.data: + self[key] = default + return self.data[key] + + def __ior__(self, other: Any) -> Self: # type: ignore[override,misc] + """Update from a mapping or item iterable while preserving extended containers.""" + self.update(other) + return self + + def deep_merge(self, *mappings: Mapping[str, Any]) -> ExtendedDict: + """Return a deeply merged copy.""" + from extended_data.containers.factory import extend_data, to_builtin + + return extend_data(deep_merge(to_builtin(self.data), *(to_builtin(mapping) for mapping in mappings))) + + def flatten(self, *, separator: str = ".") -> ExtendedDict: + """Return a flattened copy.""" + from extended_data.containers.factory import extend_data, to_builtin + + return extend_data(flatten_map(to_builtin(self.data), separator=separator)) + + def filter( + self, + *, + allowlist: list[str] | None = None, + denylist: list[str] | None = None, + ) -> ExtendedTuple[ExtendedDict]: + """Return accepted and rejected mapping entries.""" + from extended_data.containers.factory import extend_data, to_builtin + from extended_data.containers.sequences import ExtendedTuple + + accepted, rejected = filter_map(to_builtin(self.data), allowlist=allowlist, denylist=denylist) + return ExtendedTuple((extend_data(accepted), extend_data(rejected))) + + def compact(self) -> ExtendedDict: + """Return a copy without values considered empty.""" + from extended_data.containers.factory import extend_data, to_builtin + + return extend_data(all_non_empty_in_dict(to_builtin(self.data))) + + def deduplicate(self) -> ExtendedDict: + """Return a copy with nested duplicate list values removed.""" + from extended_data.containers.factory import extend_data, to_builtin + + return extend_data(deduplicate_map(to_builtin(self.data))) + + def unhump(self, *, drop_without_prefix: str | None = None) -> ExtendedDict: + """Return a copy with camelCase keys converted to snake_case.""" + from extended_data.containers.factory import extend_data, to_builtin + + return extend_data(unhump_map(to_builtin(self.data), drop_without_prefix=drop_without_prefix)) + + def all_values(self) -> ExtendedList[Any]: + """Return all values from the nested mapping.""" + from extended_data.containers.factory import extend_data, to_builtin + + return extend_data(all_values_from_map(to_builtin(self.data))) + + def split_by_type(self, *, primitive_only: bool = False) -> ExtendedDict: + """Return mapping entries grouped by value type name.""" + from extended_data.containers.factory import extend_data, to_builtin + + grouped = split_dict_by_type(to_builtin(self.data), primitive_only=primitive_only) + return extend_data({type_key.__name__: values for type_key, values in grouped.items()}) + + def first_non_empty_value(self, *keys: str) -> Any: + """Return the first non-empty value for the provided keys.""" + from extended_data.containers.factory import extend_data, to_builtin + + return extend_data(first_non_empty_value_from_map(to_builtin(self.data), *keys)) + + def first_non_empty_entry(self, *keys: str) -> ExtendedDict: + """Return the first non-empty keyed entry for the provided keys.""" + from extended_data.containers.factory import extend_data, to_builtin + + return extend_data(any_non_empty(to_builtin(self.data), *keys)) + + def non_empty_entries(self, *keys: str) -> ExtendedList[ExtendedDict]: + """Return all non-empty keyed entries for the provided keys.""" + from extended_data.containers.factory import extend_data, to_builtin + + return extend_data(list(yield_non_empty(to_builtin(self.data), *keys))) + + def reconstruct_special_types(self, *, fail_silently: bool = False) -> ExtendedDict: + """Return a copy with string-like special values reconstructed.""" + from extended_data.containers.factory import extend_data, to_builtin + + return extend_data(reconstruct_special_types(to_builtin(self.data), fail_silently=fail_silently)) + + def to_export_safe(self, *, export_to_yaml: bool = False) -> Any: + """Return this mapping converted to export-safe primitive data.""" + from extended_data.io.exporters import make_raw_data_export_safe + + return make_raw_data_export_safe(self.data, export_to_yaml=export_to_yaml) + + def wrap_for_export(self, allow_encoding: bool | str = True, **format_opts: Any) -> str: + """Return this mapping wrapped as an encoded export string.""" + from extended_data.io.exporters import wrap_raw_data_for_export + + return wrap_raw_data_for_export(self.data, allow_encoding=allow_encoding, **format_opts) diff --git a/src/extended_data/containers/sequences.py b/src/extended_data/containers/sequences.py new file mode 100644 index 0000000..54cadfd --- /dev/null +++ b/src/extended_data/containers/sequences.py @@ -0,0 +1,403 @@ +"""Extended sequence containers built on Tier 1 primitives.""" + +from __future__ import annotations + +from collections import UserList +from collections.abc import Callable, Iterable, Iterator, MutableSet +from operator import index as operator_index +from typing import Any, SupportsIndex, TypeVar, cast, overload + +from extended_data.containers.mappings import ExtendedDict +from extended_data.primitives.mappings import zipmap as primitive_zipmap +from extended_data.primitives.sequences import filter_list, flatten_list +from extended_data.primitives.splitting import split_list_by_type +from extended_data.primitives.state import first_non_empty as primitive_first_non_empty +from extended_data.primitives.state import is_nothing +from extended_data.primitives.types import make_hashable, reconstruct_special_types + + +T = TypeVar("T") +U = TypeVar("U") + + +class ExtendedList(UserList[T]): + """List wrapper with chainable primitive operations.""" + + def __init__(self, initlist: Iterable[T] | None = None) -> None: + """Initialize the extended list.""" + super().__init__() + self.extend(initlist or []) + + @staticmethod + def _wrap_item(item: T) -> T: + """Promote nested built-in containers to extended containers.""" + from extended_data.containers.factory import extend_data + + return cast(T, extend_data(item)) + + @overload + def __setitem__(self, i: SupportsIndex, item: T) -> None: ... + + @overload + def __setitem__(self, i: slice, item: Iterable[T]) -> None: ... + + def __setitem__(self, i: SupportsIndex | slice, item: T | Iterable[T]) -> None: + """Set values while preserving extended nested containers.""" + if isinstance(i, slice): + self.data[i] = [self._wrap_item(value) for value in cast(Iterable[T], item)] + return + self.data[i] = self._wrap_item(cast(T, item)) + + def append(self, item: T) -> None: + """Append a value while preserving extended nested containers.""" + self.data.append(self._wrap_item(item)) + + def extend(self, other: Iterable[T]) -> None: + """Extend values while preserving extended nested containers.""" + self.data.extend(self._wrap_item(item) for item in other) + + def __iadd__(self, other: Iterable[T]) -> ExtendedList[T]: + """Extend in place while preserving extended nested containers.""" + self.extend(other) + return self + + def __imul__(self, count: SupportsIndex) -> ExtendedList[T]: + """Repeat in place while preserving extended nested containers.""" + self.data *= operator_index(count) + self.data[:] = [self._wrap_item(item) for item in self.data] + return self + + def insert(self, i: int, item: T) -> None: + """Insert a value while preserving extended nested containers.""" + self.data.insert(i, self._wrap_item(item)) + + def flatten(self) -> ExtendedList[Any]: + """Return a recursively flattened copy.""" + from extended_data.containers.factory import extend_data, to_builtin + + return extend_data(flatten_list(to_builtin(self.data))) + + def compact(self) -> ExtendedList[T]: + """Return a copy without values considered empty.""" + return ExtendedList(item for item in self.data if not is_nothing(item)) + + def map(self, func: Callable[[T], U]) -> ExtendedList[U]: + """Return a copy with a callable applied to each item.""" + return ExtendedList(func(item) for item in self.data) + + def filter(self, predicate: Callable[[T], bool]) -> ExtendedList[T]: + """Return a copy containing items accepted by a predicate.""" + return ExtendedList(item for item in self.data if predicate(item)) + + def filter_values( + self, + *, + allowlist: Iterable[T] | None = None, + denylist: Iterable[T] | None = None, + ) -> ExtendedList[T]: + """Return a copy filtered by explicit allowed and denied values.""" + return ExtendedList(filter_list(self.data, allowlist=allowlist, denylist=denylist)) + + def split_by_type(self, *, primitive_only: bool = False) -> ExtendedDict: + """Return values grouped by type name.""" + from extended_data.containers.factory import extend_data, to_builtin + + grouped = split_list_by_type(to_builtin(self.data), primitive_only=primitive_only) + return extend_data({type_key.__name__: values for type_key, values in grouped.items()}) + + def first_non_empty(self) -> T | None: + """Return the first value not considered empty.""" + return cast(T | None, primitive_first_non_empty(*self.data)) + + def zipmap(self, values: Iterable[str]) -> ExtendedDict: + """Return an extended mapping from this list's values to provided values.""" + from extended_data.containers.factory import extend_data, to_builtin + + keys = [str(item) for item in to_builtin(self.data)] + mapped_values = [str(item) for item in to_builtin(list(values))] + return extend_data(primitive_zipmap(keys, mapped_values)) + + def reconstruct_special_types(self, *, fail_silently: bool = False) -> ExtendedList[Any]: + """Return a copy with string-like special values reconstructed.""" + from extended_data.containers.factory import extend_data, to_builtin + + return extend_data(reconstruct_special_types(to_builtin(self.data), fail_silently=fail_silently)) + + def to_export_safe(self, *, export_to_yaml: bool = False) -> Any: + """Return this list converted to export-safe primitive data.""" + from extended_data.io.exporters import make_raw_data_export_safe + + return make_raw_data_export_safe(self.data, export_to_yaml=export_to_yaml) + + def wrap_for_export(self, allow_encoding: bool | str = True, **format_opts: Any) -> str: + """Return this list wrapped as an encoded export string.""" + from extended_data.io.exporters import wrap_raw_data_for_export + + return wrap_raw_data_for_export(self.data, allow_encoding=allow_encoding, **format_opts) + + def unique(self) -> ExtendedList[T]: + """Return a copy with duplicate values removed while preserving order.""" + seen: set[Any] = set() + values: list[T] = [] + for item in self.data: + marker = make_hashable(item) + if marker in seen: + continue + seen.add(marker) + values.append(item) + return ExtendedList(values) + + +class ExtendedTuple(tuple[T, ...]): + """Tuple wrapper with immutable chainable sequence operations.""" + + __slots__ = () + + def __new__(cls, values: Iterable[T] | None = None) -> ExtendedTuple[T]: + """Initialize the extended tuple.""" + items = () if values is None else values + return super().__new__(cls, (cls._wrap_item(item) for item in items)) + + @staticmethod + def _wrap_item(item: T) -> T: + """Promote nested built-in containers to extended containers.""" + from extended_data.containers.factory import extend_data + + return cast(T, extend_data(item)) + + @overload + def __getitem__(self, index: SupportsIndex) -> T: ... + + @overload + def __getitem__(self, index: slice) -> ExtendedTuple[T]: ... + + def __getitem__(self, index: SupportsIndex | slice) -> T | ExtendedTuple[T]: + """Return sliced values as ExtendedTuple instances.""" + value = super().__getitem__(index) + if isinstance(index, slice): + return ExtendedTuple(cast(tuple[T, ...], value)) + return cast(T, value) + + @overload + def __add__(self, other: tuple[T, ...]) -> ExtendedTuple[T]: ... + + @overload + def __add__(self, other: tuple[U, ...]) -> ExtendedTuple[T | U]: ... + + def __add__(self, other: tuple[Any, ...]) -> ExtendedTuple[Any]: + """Concatenate tuples while preserving the ExtendedTuple surface.""" + return ExtendedTuple((*tuple(self), *other)) + + def __radd__(self, other: tuple[Any, ...]) -> ExtendedTuple[Any]: + """Concatenate tuples while preserving the ExtendedTuple surface.""" + return ExtendedTuple((*other, *tuple(self))) + + def __mul__(self, count: SupportsIndex) -> ExtendedTuple[T]: + """Repeat tuple values while preserving the ExtendedTuple surface.""" + return ExtendedTuple(tuple(self) * operator_index(count)) + + def __rmul__(self, count: SupportsIndex) -> ExtendedTuple[T]: + """Repeat tuple values while preserving the ExtendedTuple surface.""" + return self * count + + def flatten(self) -> ExtendedTuple[Any]: + """Return a recursively flattened tuple copy.""" + from extended_data.containers.factory import to_builtin + + def _flatten(items: Iterable[Any]) -> list[Any]: + flattened: list[Any] = [] + for item in items: + plain_item = to_builtin(item) + if isinstance(plain_item, list | tuple): + flattened.extend(_flatten(plain_item)) + else: + flattened.append(plain_item) + return flattened + + return ExtendedTuple(_flatten(self)) + + def compact(self) -> ExtendedTuple[T]: + """Return a copy without values considered empty.""" + return ExtendedTuple(item for item in self if not is_nothing(item)) + + def map(self, func: Callable[[T], U]) -> ExtendedTuple[U]: + """Return a copy with a callable applied to each item.""" + return ExtendedTuple(func(item) for item in self) + + def filter(self, predicate: Callable[[T], bool]) -> ExtendedTuple[T]: + """Return a copy containing items accepted by a predicate.""" + return ExtendedTuple(item for item in self if predicate(item)) + + def unique(self) -> ExtendedTuple[T]: + """Return a copy with duplicate values removed while preserving order.""" + seen: set[Any] = set() + values: list[T] = [] + for item in self: + marker = make_hashable(item) + if marker in seen: + continue + seen.add(marker) + values.append(item) + return ExtendedTuple(values) + + def split_by_type(self, *, primitive_only: bool = False) -> ExtendedDict: + """Return values grouped by type name while keeping tuple-shaped groups.""" + from extended_data.containers.factory import extend_data, to_builtin + + grouped = split_list_by_type(list(to_builtin(self)), primitive_only=primitive_only) + return extend_data({type_key.__name__: tuple(values) for type_key, values in grouped.items()}) + + def first_non_empty(self) -> T | None: + """Return the first value not considered empty.""" + return cast(T | None, primitive_first_non_empty(*self)) + + def zipmap(self, values: Iterable[str]) -> ExtendedDict: + """Return an extended mapping from this tuple's values to provided values.""" + from extended_data.containers.factory import extend_data, to_builtin + + keys = [str(item) for item in to_builtin(tuple(self))] + mapped_values = [str(item) for item in to_builtin(list(values))] + return extend_data(primitive_zipmap(keys, mapped_values)) + + def reconstruct_special_types(self, *, fail_silently: bool = False) -> ExtendedTuple[Any]: + """Return a copy with string-like special values reconstructed.""" + from extended_data.containers.factory import extend_data, to_builtin + + return extend_data(reconstruct_special_types(to_builtin(tuple(self)), fail_silently=fail_silently)) + + def to_export_safe(self, *, export_to_yaml: bool = False) -> Any: + """Return this tuple converted to export-safe primitive data.""" + from extended_data.io.exporters import make_raw_data_export_safe + + return make_raw_data_export_safe(tuple(self), export_to_yaml=export_to_yaml) + + def wrap_for_export(self, allow_encoding: bool | str = True, **format_opts: Any) -> str: + """Return this tuple wrapped as an encoded export string.""" + from extended_data.io.exporters import wrap_raw_data_for_export + + return wrap_raw_data_for_export(tuple(self), allow_encoding=allow_encoding, **format_opts) + + def to_tuple(self) -> tuple[T, ...]: + """Return a plain tuple copy.""" + return tuple(self) + + +class ExtendedSet(MutableSet[T]): + """Set wrapper with explicit chainable operations.""" + + def __init__(self, values: Iterable[T] | None = None) -> None: + """Initialize the extended set.""" + self._data: set[T] = set() + for value in values or []: + self.add(value) + + @staticmethod + def _wrap_item(item: T) -> T: + """Promote nested built-in containers to extended containers.""" + from extended_data.containers.factory import extend_data + + return cast(T, extend_data(item)) + + def __contains__(self, value: object) -> bool: + """Return whether the set contains a value.""" + return value in self._data + + def __iter__(self) -> Iterator[T]: + """Iterate over set values.""" + return iter(self._data) + + def __len__(self) -> int: + """Return the number of set values.""" + return len(self._data) + + def __repr__(self) -> str: + """Return a value-oriented representation.""" + return f"{self.__class__.__name__}({self._data!r})" + + def add(self, value: T) -> None: + """Add a value to the set.""" + self._data.add(self._wrap_item(value)) + + def update(self, *others: Iterable[T]) -> None: + """Add values from one or more iterables.""" + for other in others: + for value in other: + self.add(value) + + def discard(self, value: T) -> None: + """Remove a value from the set if present.""" + self._data.discard(value) + + def copy(self) -> ExtendedSet[T]: + """Return a shallow copy.""" + return ExtendedSet(self._data) + + def compact(self) -> ExtendedSet[T]: + """Return a copy without values considered empty.""" + return ExtendedSet(item for item in self._data if not is_nothing(item)) + + def reconstruct_special_types(self, *, fail_silently: bool = False) -> ExtendedSet[Any]: + """Return a copy with string-like special values reconstructed.""" + from extended_data.containers.factory import extend_data, to_builtin + + return extend_data(reconstruct_special_types(to_builtin(self._data), fail_silently=fail_silently)) + + def to_export_safe(self, *, export_to_yaml: bool = False) -> Any: + """Return this set converted to export-safe primitive data.""" + from extended_data.io.exporters import make_raw_data_export_safe + + return make_raw_data_export_safe(self._data, export_to_yaml=export_to_yaml) + + def wrap_for_export(self, allow_encoding: bool | str = True, **format_opts: Any) -> str: + """Return this set wrapped as an encoded export string.""" + from extended_data.io.exporters import wrap_raw_data_for_export + + return wrap_raw_data_for_export(self._data, allow_encoding=allow_encoding, **format_opts) + + def union(self, *others: Iterable[T]) -> ExtendedSet[T]: + """Return a union with other iterables.""" + result = set(self._data) + for other in others: + result.update(other) + return ExtendedSet(result) + + def intersection(self, *others: Iterable[T]) -> ExtendedSet[T]: + """Return an intersection with other iterables.""" + result = set(self._data) + for other in others: + result.intersection_update(other) + return ExtendedSet(result) + + def difference(self, *others: Iterable[T]) -> ExtendedSet[T]: + """Return a difference against other iterables.""" + result = set(self._data) + for other in others: + result.difference_update(other) + return ExtendedSet(result) + + def symmetric_difference(self, other: Iterable[T]) -> ExtendedSet[T]: + """Return a symmetric difference against another iterable.""" + result = set(self._data) + for value in other: + wrapped = self._wrap_item(value) + if wrapped in result: + result.remove(wrapped) + else: + result.add(wrapped) + return ExtendedSet(result) + + def intersection_update(self, *others: Iterable[T]) -> None: + """Keep only values found in all other iterables.""" + self._data = self.intersection(*others)._data + + def difference_update(self, *others: Iterable[T]) -> None: + """Remove values found in other iterables.""" + self._data = self.difference(*others)._data + + def symmetric_difference_update(self, other: Iterable[T]) -> None: + """Replace values with the symmetric difference against another iterable.""" + self._data = self.symmetric_difference(other)._data + + def to_set(self) -> set[T]: + """Return a plain set copy.""" + return set(self._data) diff --git a/src/extended_data/containers/strings.py b/src/extended_data/containers/strings.py new file mode 100644 index 0000000..2706a8f --- /dev/null +++ b/src/extended_data/containers/strings.py @@ -0,0 +1,276 @@ +"""Extended string container built on Tier 1 primitives.""" + +from __future__ import annotations + +import datetime + +from collections import UserString +from collections.abc import Iterable, Mapping +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import extended_data.primitives.matching as primitive_matching + +from extended_data.primitives.string_transforms import ( + humanize, + ordinalize, + pluralize, + singularize, + titleize, + to_camel_case, + to_kebab_case, + to_pascal_case, + to_snake_case, +) +from extended_data.primitives.strings import ( + is_url, + lower_first_char, + sanitize_key, + titleize_name, + truncate, + upper_first_char, +) +from extended_data.primitives.types import ( + reconstruct_special_type, + string_to_bool, + string_to_date, + string_to_datetime, + string_to_float, + string_to_int, + string_to_path, + string_to_time, +) + + +if TYPE_CHECKING: + from extended_data.containers.sequences import ExtendedList, ExtendedTuple + + +def _coerce_string_argument(value: str | UserString) -> str: + """Coerce stdlib user strings while preserving normal str errors elsewhere.""" + return str(value) if isinstance(value, UserString) else value + + +class ExtendedString(UserString): + """String wrapper with chainable primitive operations.""" + + def lower_first(self) -> ExtendedString: + """Return a copy with the first character lowercased.""" + return ExtendedString(lower_first_char(self.data)) + + def upper_first(self) -> ExtendedString: + """Return a copy with the first character uppercased.""" + return ExtendedString(upper_first_char(self.data)) + + def remove_prefix(self, prefix: str) -> ExtendedString: + """Return a copy with a leading prefix removed.""" + return ExtendedString(self.data.removeprefix(str(prefix))) + + def remove_suffix(self, suffix: str) -> ExtendedString: + """Return a copy with a trailing suffix removed.""" + return ExtendedString(self.data.removesuffix(str(suffix))) + + def sanitize(self, delim: str = "_") -> ExtendedString: + """Return a key-safe copy.""" + return ExtendedString(sanitize_key(self.data, delim=delim)) + + def truncate(self, max_length: int, ender: str = "...") -> ExtendedString: + """Return a truncated copy.""" + return ExtendedString(truncate(self.data, max_length=max_length, ender=ender)) + + def titleize_name(self) -> ExtendedString: + """Return a titleized name copy.""" + return ExtendedString(titleize_name(self.data)) + + def to_snake_case(self) -> ExtendedString: + """Return a snake_case copy.""" + return ExtendedString(to_snake_case(self.data)) + + def to_camel_case(self, *, uppercase_first: bool = False) -> ExtendedString: + """Return a camelCase copy.""" + return ExtendedString(to_camel_case(self.data, uppercase_first=uppercase_first)) + + def to_pascal_case(self) -> ExtendedString: + """Return a PascalCase copy.""" + return ExtendedString(to_pascal_case(self.data)) + + def to_kebab_case(self) -> ExtendedString: + """Return a kebab-case copy.""" + return ExtendedString(to_kebab_case(self.data)) + + def pluralize(self) -> ExtendedString: + """Return a pluralized copy.""" + return ExtendedString(pluralize(self.data)) + + def singularize(self) -> ExtendedString: + """Return a singularized copy.""" + return ExtendedString(singularize(self.data)) + + def humanize(self) -> ExtendedString: + """Return a human-readable copy.""" + return ExtendedString(humanize(self.data)) + + def titleize(self) -> ExtendedString: + """Return a title-case copy.""" + return ExtendedString(titleize(self.data)) + + def ordinalize(self) -> ExtendedString: + """Return an ordinalized copy.""" + return ExtendedString(ordinalize(self.data)) + + def format(self, *args: object, **kwargs: object) -> ExtendedString: # type: ignore[override] + """Format values into an extended string.""" + return ExtendedString(self.data.format(*args, **kwargs)) + + def format_map(self, mapping: Mapping[str, object]) -> ExtendedString: # type: ignore[override] + """Format mapping values into an extended string.""" + return ExtendedString(self.data.format_map(mapping)) + + def split(self, sep: str | UserString | None = None, maxsplit: int = -1) -> ExtendedList[ExtendedString]: # type: ignore[override] + """Split into extended string parts.""" + from extended_data.containers.sequences import ExtendedList + + separator = None if sep is None else _coerce_string_argument(sep) + return ExtendedList(ExtendedString(part) for part in self.data.split(separator, maxsplit)) + + def rsplit(self, sep: str | UserString | None = None, maxsplit: int = -1) -> ExtendedList[ExtendedString]: # type: ignore[override] + """Split from the right into extended string parts.""" + from extended_data.containers.sequences import ExtendedList + + separator = None if sep is None else _coerce_string_argument(sep) + return ExtendedList(ExtendedString(part) for part in self.data.rsplit(separator, maxsplit)) + + def splitlines(self, keepends: bool = False) -> ExtendedList[ExtendedString]: # type: ignore[override] + """Split lines into extended string parts.""" + from extended_data.containers.sequences import ExtendedList + + return ExtendedList(ExtendedString(part) for part in self.data.splitlines(keepends)) + + def partition(self, sep: str | UserString) -> ExtendedTuple[ExtendedString]: # type: ignore[override] + """Partition into extended string parts.""" + from extended_data.containers.sequences import ExtendedTuple + + return ExtendedTuple(ExtendedString(part) for part in self.data.partition(_coerce_string_argument(sep))) + + def rpartition(self, sep: str | UserString) -> ExtendedTuple[ExtendedString]: # type: ignore[override] + """Partition from the right into extended string parts.""" + from extended_data.containers.sequences import ExtendedTuple + + return ExtendedTuple(ExtendedString(part) for part in self.data.rpartition(_coerce_string_argument(sep))) + + def join(self, seq: Iterable[str | UserString]) -> ExtendedString: # type: ignore[override] + """Join string-like values into an extended string.""" + return ExtendedString(self.data.join(_coerce_string_argument(item) for item in seq)) + + def is_partial_match(self, other: str | None, *, check_prefix_only: bool = False) -> bool: + """Return whether this string partially matches another string.""" + return primitive_matching.is_partial_match(self.data, other, check_prefix_only=check_prefix_only) + + def is_non_empty_match(self, other: object) -> bool: + """Return whether this string matches another non-empty string value.""" + return primitive_matching.is_non_empty_match(self.data, other) + + def is_url(self) -> bool: + """Return whether the string is a URL.""" + return is_url(self.data) + + def to_bool(self, *, raise_on_error: bool = False) -> bool | None: + """Return a boolean parsed from the string.""" + return string_to_bool(self.data, raise_on_error=raise_on_error) + + def to_float(self, *, raise_on_error: bool = False) -> float | None: + """Return a float parsed from the string.""" + return string_to_float(self.data, raise_on_error=raise_on_error) + + def to_int(self, *, raise_on_error: bool = False) -> int | None: + """Return an integer parsed from the string.""" + return string_to_int(self.data, raise_on_error=raise_on_error) + + def to_path(self, *, raise_on_error: bool = False) -> Path | None: + """Return a path parsed from the string.""" + return string_to_path(self.data, raise_on_error=raise_on_error) + + def to_date(self, *, raise_on_error: bool = False) -> datetime.date | None: + """Return a date parsed from the string.""" + return string_to_date(self.data, raise_on_error=raise_on_error) + + def to_datetime(self, *, raise_on_error: bool = False) -> datetime.datetime | None: + """Return a datetime parsed from the string.""" + return string_to_datetime(self.data, raise_on_error=raise_on_error) + + def to_time(self, *, raise_on_error: bool = False) -> datetime.time | None: + """Return a time parsed from the string.""" + return string_to_time(self.data, raise_on_error=raise_on_error) + + def reconstruct_special_type(self, *, fail_silently: bool = False) -> object: + """Return the string reconstructed as a known scalar or structured value.""" + from extended_data.containers.factory import extend_data + + return extend_data(reconstruct_special_type(self.data, fail_silently=fail_silently)) + + def decode_json(self, *, as_extended: bool = True) -> Any: + """Decode this JSON string, promoting structured values by default.""" + from extended_data.containers.factory import extend_data + from extended_data.primitives.formats.json import decode_json + + decoded = decode_json(self.data) + return extend_data(decoded) if as_extended else decoded + + def decode_yaml(self, *, as_extended: bool = True) -> Any: + """Decode this YAML string, promoting structured values by default.""" + from extended_data.containers.factory import extend_data + from extended_data.primitives.formats.yaml import decode_yaml + + decoded = decode_yaml(self.data) + return extend_data(decoded) if as_extended else decoded + + def decode_toml(self, *, as_extended: bool = True) -> Any: + """Decode this TOML string, promoting structured values by default.""" + from extended_data.containers.factory import extend_data + from extended_data.primitives.formats.toml import decode_toml + + decoded = decode_toml(self.data) + return extend_data(decoded) if as_extended else decoded + + def decode_hcl2(self, *, as_extended: bool = True) -> Any: + """Decode this HCL2 string, promoting structured values by default.""" + from extended_data.containers.factory import extend_data + from extended_data.primitives.formats.hcl import decode_hcl2 + + decoded = decode_hcl2(self.data) + return extend_data(decoded) if as_extended else decoded + + def encode_base64(self, *, wrap_raw_data: bool = True) -> ExtendedString: + """Return this string encoded as Base64.""" + from extended_data.io.base64 import base64_encode + + return ExtendedString(base64_encode(self.data, wrap_raw_data=wrap_raw_data)) + + def decode_base64( + self, + unwrap_raw_data: bool = True, + encoding: str = "yaml", + *, + as_extended: bool = True, + ) -> Any: + """Decode this Base64 string, promoting structured values by default.""" + from extended_data.io.base64 import base64_decode + + return base64_decode( + self.data, + unwrap_raw_data=unwrap_raw_data, + encoding=encoding, + as_extended=as_extended, + ) + + def to_export_safe(self, *, export_to_yaml: bool = False) -> Any: + """Return this value converted to export-safe primitive data.""" + from extended_data.io.exporters import make_raw_data_export_safe + + return make_raw_data_export_safe(self.data, export_to_yaml=export_to_yaml) + + def wrap_for_export(self, allow_encoding: bool | str = True, **format_opts: Any) -> str: + """Return this value wrapped as an encoded export string.""" + from extended_data.io.exporters import wrap_raw_data_for_export + + return wrap_raw_data_for_export(self.data, allow_encoding=allow_encoding, **format_opts) diff --git a/src/extended_data/import_utils.py b/src/extended_data/import_utils.py deleted file mode 100644 index 6f16ddb..0000000 --- a/src/extended_data/import_utils.py +++ /dev/null @@ -1,45 +0,0 @@ -"""This module provides utilities for unwrapping data after import.""" - -from __future__ import annotations - -from typing import Any - -from extended_data.hcl2_utils import decode_hcl2 -from extended_data.json_utils import decode_json -from extended_data.serialization_utils import normalize_data_encoding -from extended_data.string_data_type import bytestostr -from extended_data.toml_utils import decode_toml -from extended_data.yaml_utils import decode_yaml - - -def unwrap_raw_data_from_import( - wrapped_data: str | memoryview | bytes | bytearray, - encoding: str = "yaml", -) -> Any: - """Unwraps the data that was wrapped for import. - - Args: - wrapped_data (str | memoryview | bytes | bytearray): The wrapped data. - encoding (str): The encoding format (default is 'yaml'). - - Returns: - Any: The unwrapped data. - - Raises: - ValueError: If the encoding format is unsupported. - """ - normalized_encoding = normalize_data_encoding(encoding) - - if normalized_encoding == "yaml": - return decode_yaml(wrapped_data) - if normalized_encoding == "json": - return decode_json(wrapped_data) - if normalized_encoding == "toml": - return decode_toml(wrapped_data) - if normalized_encoding == "hcl": - return decode_hcl2(wrapped_data) - if normalized_encoding == "raw": - return bytestostr(wrapped_data) - - error_message = f"Unsupported encoding format: {encoding}" - raise ValueError(error_message) diff --git a/src/extended_data/inputs/__main__.py b/src/extended_data/inputs/__main__.py index aaf93ab..d1c72e3 100644 --- a/src/extended_data/inputs/__main__.py +++ b/src/extended_data/inputs/__main__.py @@ -1,35 +1,34 @@ -"""Module to handle directed inputs for the InputProvider library. +"""Tier 3 directed input processing for the extended-data package. -This module provides functionality for managing inputs from various sources -(environment, stdin) and allows for dynamic merging, freezing, and thawing -of inputs. It includes methods to decode inputs from JSON, YAML, and Base64 -formats, as well as handling boolean and integer conversions. +This module manages inputs from environment variables, stdin, and explicit +mappings. It can merge, replace, snapshot, freeze, and thaw input state while +keeping public snapshots in Tier 2 containers. It also decodes inputs from JSON, +YAML, and Base64 and coerces scalar values through Tier 1 type primitives. """ from __future__ import annotations import binascii -import json import os import sys from copy import deepcopy from typing import TYPE_CHECKING, Any -from case_insensitive_dict import CaseInsensitiveDict from deepmerge import Merger # type: ignore[attr-defined] -from yaml import YAMLError - -from extended_data import ( - base64_decode, - decode_json, - decode_yaml, - is_nothing, - strtobool, - strtodatetime, - strtofloat, - strtoint, - strtopath, + +from extended_data.containers.factory import extend_data, to_builtin +from extended_data.containers.mappings import ExtendedDict +from extended_data.io.base64 import base64_decode +from extended_data.io.files import decode_file +from extended_data.primitives.formats.errors import DataDecodeError +from extended_data.primitives.state import is_nothing +from extended_data.primitives.types import ( + string_to_bool, + string_to_datetime, + string_to_float, + string_to_int, + string_to_path, ) @@ -37,14 +36,15 @@ from collections.abc import Mapping -class InputProvider: - """A class to manage and process directed inputs from environment variables. +_MISSING = object() - stdin, or provided dictionaries. + +class InputProvider: + """Manage directed inputs from environment variables, stdin, or mappings. Attributes: - inputs (CaseInsensitiveDict): Dictionary to store inputs. - frozen_inputs (CaseInsensitiveDict): Dictionary to store frozen inputs. + inputs (ExtendedDict): Dictionary to store inputs. + frozen_inputs (ExtendedDict): Dictionary to store frozen inputs. from_stdin (bool): Flag indicating if inputs were read from stdin. merger (Merger): Object to manage deep merging of dictionaries. """ @@ -80,20 +80,20 @@ def __init__( env_inputs = self._filtered_environment(os.environ, env_prefix=env_prefix, strip_prefix=strip_env_prefix) current_inputs = self._merge_inputs(env_inputs, current_inputs) - if from_stdin and not strtobool(os.getenv("OVERRIDE_STDIN", "False")): + if from_stdin and not string_to_bool(os.getenv("OVERRIDE_STDIN", "False")): stdin_inputs = self._load_from_stdin() current_inputs = self._merge_inputs(stdin_inputs, current_inputs) self.from_stdin = from_stdin - self.inputs: CaseInsensitiveDict[str, Any] = CaseInsensitiveDict(current_inputs) - self.frozen_inputs: CaseInsensitiveDict[str, Any] = CaseInsensitiveDict() + self.inputs: ExtendedDict = ExtendedDict(current_inputs) + self.frozen_inputs: ExtendedDict = ExtendedDict() @staticmethod def _normalize_inputs(inputs: Mapping[str, Any] | None) -> dict[str, Any]: if inputs is None or is_nothing(inputs): return {} - return dict(inputs) + return to_builtin(dict(inputs)) @staticmethod def _filtered_environment( @@ -113,10 +113,10 @@ def _filtered_environment( def _merge_inputs(self, base: Mapping[str, Any], incoming: Mapping[str, Any]) -> dict[str, Any]: if is_nothing(incoming): - return deepcopy(dict(base)) + return deepcopy(to_builtin(base)) - clean_base = deepcopy(dict(base)) - clean_incoming = deepcopy(dict(incoming)) + clean_base = deepcopy(to_builtin(base)) + clean_incoming = deepcopy(to_builtin(incoming)) return self.merger.merge(clean_base, clean_incoming) @@ -128,10 +128,13 @@ def _load_from_stdin() -> dict[str, Any]: return {} try: - decoded_stdin: dict[str, Any] = json.loads(inputs_from_stdin) + decoded_stdin = decode_file(inputs_from_stdin, suffix="json", as_extended=False) + if not isinstance(decoded_stdin, dict): + message = "Failed to decode stdin as JSON object." + raise TypeError(message) return decoded_stdin - except json.JSONDecodeError as exc: - message = f"Failed to decode stdin:\n{inputs_from_stdin}" + except DataDecodeError as exc: + message = f"Failed to decode stdin as JSON ({len(inputs_from_stdin)} characters)." raise RuntimeError(message) from exc @staticmethod @@ -143,11 +146,24 @@ def _coerce_text(value: Any) -> Any: try: return value.decode("utf-8") except UnicodeDecodeError as exc: - message = f"Failed to decode bytes to string: {value!r}" + message = f"Failed to decode {type(value).__name__} input as UTF-8 text." raise RuntimeError(message) from exc return value + @staticmethod + def _format_available_keys(inputs: Mapping[str, Any]) -> str: + """Format available input keys without exposing their values.""" + if not inputs: + return "none" + + keys = sorted(str(key) for key in inputs) + return ", ".join(keys[:20]) + (f", ... ({len(keys)} total)" if len(keys) > 20 else "") + + @staticmethod + def _return_value(value: Any, *, as_extended: bool) -> Any: + return extend_data(value) if as_extended else value + def get_input( self, k: str, @@ -158,6 +174,7 @@ def get_input( is_float: bool = False, is_path: bool = False, is_datetime: bool = False, + as_extended: bool = False, ) -> Any: """Retrieves an input by key, with options for type conversion and default values. @@ -174,50 +191,59 @@ def get_input( is_float (bool): Whether to convert the input to a float. is_path (bool): Whether to convert the input to a Path object. is_datetime (bool): Whether to convert the input to a datetime object. + as_extended (bool): Whether to wrap the returned value in Tier 2 containers. Returns: Any: The retrieved input, potentially converted or defaulted. """ - inp = self.inputs.get(k, default) + inp = to_builtin(self.inputs.get(k, default)) if is_nothing(inp): inp = default if is_bool and not isinstance(inp, bool): - inp = strtobool(inp) + try: + inp = string_to_bool(str(inp), raise_on_error=True) + except (TypeError, ValueError) as exc: + message = f"Input {k} cannot be converted to boolean." + raise RuntimeError(message) from exc if is_integer and inp is not None and not isinstance(inp, int): try: - inp = strtoint(inp) + inp = string_to_int(str(inp), raise_on_error=True) except (TypeError, ValueError) as exc: - message = f"Input {k} cannot be converted to integer: {inp!r}" + message = f"Input {k} cannot be converted to integer." raise RuntimeError(message) from exc if is_float and inp is not None and not isinstance(inp, float): try: - inp = strtofloat(str(inp)) + inp = string_to_float(str(inp), raise_on_error=True) except (TypeError, ValueError) as exc: - message = f"Input {k} cannot be converted to float: {inp!r}" + message = f"Input {k} cannot be converted to float." raise RuntimeError(message) from exc if is_path and inp is not None: try: - inp = strtopath(str(inp)) + inp = string_to_path(str(inp), raise_on_error=True) except (TypeError, ValueError) as exc: - message = f"Input {k} cannot be converted to Path: {inp!r}" + message = f"Input {k} cannot be converted to Path." raise RuntimeError(message) from exc if is_datetime and inp is not None: try: - inp = strtodatetime(str(inp)) + inp = string_to_datetime(str(inp), raise_on_error=True) except (TypeError, ValueError) as exc: - message = f"Input {k} cannot be converted to datetime: {inp!r}" + message = f"Input {k} cannot be converted to datetime." raise RuntimeError(message) from exc if is_nothing(inp) and required: - message = f"Required input {k} not passed from inputs:\n{self.inputs}" + available = self._format_available_keys(self.inputs) + message = f"Required input {k} not passed. Available input keys: {available}." raise RuntimeError(message) + if as_extended: + return extend_data(inp) + return inp def decode_input( @@ -229,6 +255,7 @@ def decode_input( decode_from_yaml: bool = False, decode_from_base64: bool = False, allow_none: bool = True, + as_extended: bool = False, ) -> Any: """Decodes an input value, optionally from Base64, JSON, or YAML. @@ -241,19 +268,31 @@ def decode_input( decode_from_yaml (bool): Whether to decode the input from YAML format. decode_from_base64 (bool): Whether to decode the input from Base64. allow_none (bool): Whether to allow None as a valid return value. + as_extended (bool): Wrap decoded container values in Tier 2 Extended Data containers. Returns: Any: The decoded input, potentially converted or defaulted. """ - conf = self.get_input(k, default=default, required=required) - - if conf is None or conf == default: - return conf + raw_input = self.inputs.get(k, _MISSING) + source_present = raw_input is not _MISSING + + if not source_present: + if required: + self.get_input(k, default=default, required=True) + return self._return_value(default, as_extended=as_extended) + + conf = to_builtin(raw_input) + if conf is None: + return self._return_value(default, as_extended=as_extended) if not allow_none else None + if is_nothing(conf): + if required: + self.get_input(k, default=default, required=True) + return self._return_value(default, as_extended=as_extended) conf = self._coerce_text(conf) if not isinstance(conf, str): - return conf + return self._return_value(conf, as_extended=as_extended) if decode_from_base64: try: @@ -261,74 +300,111 @@ def decode_input( conf, unwrap_raw_data=decode_from_json or decode_from_yaml, encoding="json" if decode_from_json else "yaml", + as_extended=False, ) - except binascii.Error as exc: - message = f"Failed to decode {conf} from base64" + except (binascii.Error, DataDecodeError) as exc: + message = f"Failed to decode input {k} from Base64." raise RuntimeError(message) from exc + if not isinstance(conf, str): + if conf is None and not allow_none: + return self._return_value(default, as_extended=as_extended) + return self._return_value(conf, as_extended=as_extended) + if decode_from_yaml: try: - conf = decode_yaml(conf) - except YAMLError as exc: - message = f"Failed to decode {conf} from YAML" + conf = decode_file(conf, suffix="yaml", as_extended=as_extended) + except DataDecodeError as exc: + message = f"Failed to decode input {k} from YAML." raise RuntimeError(message) from exc elif decode_from_json: try: - conf = decode_json(conf) - except json.JSONDecodeError as exc: - message = f"Failed to decode {conf} from JSON" + conf = decode_file(conf, suffix="json", as_extended=as_extended) + except DataDecodeError as exc: + message = f"Failed to decode input {k} from JSON." raise RuntimeError(message) from exc if conf is None and not allow_none: - return default + return self._return_value(default, as_extended=as_extended) + + if (decode_from_yaml or decode_from_json) and as_extended: + return conf - return conf + return self._return_value(conf, as_extended=as_extended) - def freeze_inputs(self) -> CaseInsensitiveDict[str, Any]: + def freeze_inputs(self) -> ExtendedDict: """Freezes the current inputs, preventing further modifications until thawed. Returns: - CaseInsensitiveDict: The frozen inputs. + ExtendedDict: The frozen inputs. """ if is_nothing(self.frozen_inputs): - self.frozen_inputs = deepcopy(self.inputs) - self.inputs = CaseInsensitiveDict() + self.frozen_inputs = ExtendedDict(deepcopy(to_builtin(self.inputs))) + self.inputs = ExtendedDict() return self.frozen_inputs - def thaw_inputs(self) -> CaseInsensitiveDict[str, Any]: + def thaw_inputs(self) -> ExtendedDict: """Thaws the inputs, merging the frozen inputs back into the current inputs. Returns: - CaseInsensitiveDict: The thawed inputs. + ExtendedDict: The thawed inputs. """ if is_nothing(self.inputs): - self.inputs = deepcopy(self.frozen_inputs) - self.frozen_inputs = CaseInsensitiveDict() + self.inputs = ExtendedDict(deepcopy(to_builtin(self.frozen_inputs))) + self.frozen_inputs = ExtendedDict() return self.inputs - self.inputs = self.merger.merge(deepcopy(self.inputs), deepcopy(self.frozen_inputs)) - self.frozen_inputs = CaseInsensitiveDict() + merged = self._merge_inputs(self.inputs, self.frozen_inputs) + self.inputs = ExtendedDict(merged) + self.frozen_inputs = ExtendedDict() + return self.inputs + + def snapshot_inputs(self, *, frozen: bool = False) -> ExtendedDict: + """Return a detached Tier 2 snapshot of active or frozen inputs. + + Args: + frozen (bool): Return frozen inputs instead of active inputs. + + Returns: + ExtendedDict: A promoted copy of the requested input state. + """ + source = self.frozen_inputs if frozen else self.inputs + return ExtendedDict(deepcopy(to_builtin(source))) + + def replace_inputs(self, new_inputs: Mapping[str, Any] | None, *, clear_frozen: bool = True) -> ExtendedDict: + """Replace active inputs with a normalized Tier 2 snapshot. + + Args: + new_inputs (Mapping[str, Any] | None): New active input values. + clear_frozen (bool): Whether to clear frozen inputs after replacement. + + Returns: + ExtendedDict: The updated active input mapping. + """ + self.inputs = ExtendedDict(deepcopy(self._normalize_inputs(new_inputs))) + if clear_frozen: + self.frozen_inputs = ExtendedDict() return self.inputs - def merge_inputs(self, new_inputs: Mapping[str, Any] | None) -> CaseInsensitiveDict[str, Any]: + def merge_inputs(self, new_inputs: Mapping[str, Any] | None) -> ExtendedDict: """Merge new inputs into the current inputs using deep merge semantics. Args: new_inputs (Mapping[str, Any] | None): Incoming values to merge. Returns: - CaseInsensitiveDict[str, Any]: The updated input mapping. + ExtendedDict: The updated input mapping. """ merged = self._merge_inputs(self.inputs, self._normalize_inputs(new_inputs)) - self.inputs = CaseInsensitiveDict(merged) + self.inputs = ExtendedDict(merged) return self.inputs - def shift_inputs(self) -> CaseInsensitiveDict[str, Any]: + def shift_inputs(self) -> ExtendedDict: """Shifts between frozen and thawed inputs. Returns: - CaseInsensitiveDict: The resulting inputs after the shift. + ExtendedDict: The resulting inputs after the shift. """ if is_nothing(self.frozen_inputs): return self.freeze_inputs() diff --git a/src/extended_data/inputs/decorators.py b/src/extended_data/inputs/decorators.py index e21f9c6..80dff24 100644 --- a/src/extended_data/inputs/decorators.py +++ b/src/extended_data/inputs/decorators.py @@ -24,6 +24,7 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any +from extended_data.containers import ExtendedDict, to_builtin from extended_data.inputs.__main__ import InputProvider @@ -59,6 +60,7 @@ class InputConfig: decode_from_yaml: bool = False decode_from_base64: bool = False allow_none: bool = True + as_extended: bool = False is_bool: bool = False is_integer: bool = False is_float: bool = False @@ -80,7 +82,10 @@ def resolve(self, provider: InputProvider) -> Any | object: decode_from_yaml=self.decode_from_yaml, decode_from_base64=self.decode_from_base64, allow_none=self.allow_none, + as_extended=self.as_extended, ) + elif source_present and provider.inputs.get(key) is None and self.allow_none and not self.required: + value = None else: value = provider.get_input( key, @@ -91,6 +96,7 @@ def resolve(self, provider: InputProvider) -> Any | object: is_float=self.is_float, is_path=self.is_path, is_datetime=self.is_datetime, + as_extended=self.as_extended, ) if value is None and not source_present and self.default is _MISSING and not self.required: @@ -103,7 +109,7 @@ def resolve(self, provider: InputProvider) -> Any | object: class InputProviderMetadata: """Metadata exposed on decorated classes for runtime integrations.""" - options: dict[str, Any] = field(default_factory=dict) + options: ExtendedDict = field(default_factory=ExtendedDict) class InputContext: @@ -118,13 +124,15 @@ def __init__( env_prefix: str | None = None, strip_env_prefix: bool = False, ): - self._options: dict[str, Any] = { - "inputs": dict(inputs) if inputs else None, - "from_environment": from_environment, - "from_stdin": from_stdin, - "env_prefix": env_prefix, - "strip_env_prefix": strip_env_prefix, - } + self._options = ExtendedDict( + { + "inputs": dict(inputs) if inputs else None, + "from_environment": from_environment, + "from_stdin": from_stdin, + "env_prefix": env_prefix, + "strip_env_prefix": strip_env_prefix, + } + ) self._instance: InputProvider | None = None def refresh(self, **overrides: Any) -> None: @@ -133,9 +141,9 @@ def refresh(self, **overrides: Any) -> None: self._instance = None @property - def options(self) -> dict[str, Any]: + def options(self) -> ExtendedDict: """Current configuration (copy) used for instantiation.""" - return dict(self._options) + return ExtendedDict(to_builtin(self._options)) def resolve(self, config: InputConfig) -> Any | object: """Resolve a parameter value using the provided configuration.""" @@ -148,7 +156,7 @@ def input_provider(self) -> InputProvider: def _ensure_instance(self) -> InputProvider: if self._instance is None: - kwargs = {k: v for k, v in self._options.items() if v is not None} + kwargs = {k: v for k, v in to_builtin(self._options).items() if v is not None} self._instance = InputProvider(**kwargs) return self._instance @@ -191,7 +199,7 @@ def decorator(cls: builtins.type[Any]) -> builtins.type[Any]: if getattr(cls, "__input_provider_enabled__", False): return cls - metadata = InputProviderMetadata(options={k: v for k, v in base_options.items() if v is not None}) + metadata = InputProviderMetadata(options=ExtendedDict({k: v for k, v in base_options.items() if v is not None})) cls.__input_provider_enabled__ = True cls.__input_provider_metadata__ = metadata diff --git a/src/extended_data/io/__init__.py b/src/extended_data/io/__init__.py new file mode 100644 index 0000000..ad64010 --- /dev/null +++ b/src/extended_data/io/__init__.py @@ -0,0 +1,50 @@ +"""Tier 3 input/output processors built from primitives.""" + +from extended_data.io.base64 import base64_decode, base64_encode +from extended_data.io.exporters import make_raw_data_export_safe, wrap_raw_data_for_export +from extended_data.io.files import ( + DataFile, + FilePath, + clone_repository_to_temp, + decode_file, + delete_file, + file_path_depth, + file_path_rel_to_root, + get_encoding_for_file_path, + get_parent_repository, + get_repository_name, + get_tld, + is_url, + match_file_extensions, + read_data_file, + read_file, + resolve_local_path, + write_file, +) +from extended_data.io.importers import unwrap_raw_data_from_import + + +__all__ = [ + "DataFile", + "FilePath", + "base64_decode", + "base64_encode", + "clone_repository_to_temp", + "decode_file", + "delete_file", + "file_path_depth", + "file_path_rel_to_root", + "get_encoding_for_file_path", + "get_parent_repository", + "get_repository_name", + "get_tld", + "is_url", + "make_raw_data_export_safe", + "match_file_extensions", + "read_data_file", + "read_file", + "resolve_local_path", + "unwrap_raw_data_from_import", + "wrap_raw_data_for_export", + "write_file", +] diff --git a/src/extended_data/base64_utils.py b/src/extended_data/io/base64.py similarity index 87% rename from src/extended_data/base64_utils.py rename to src/extended_data/io/base64.py index 3b07543..19b1343 100644 --- a/src/extended_data/base64_utils.py +++ b/src/extended_data/io/base64.py @@ -9,8 +9,8 @@ from base64 import b64decode, b64encode from typing import Any -from extended_data.export_utils import wrap_raw_data_for_export -from extended_data.import_utils import unwrap_raw_data_from_import +from extended_data.io.exporters import wrap_raw_data_for_export +from extended_data.io.importers import unwrap_raw_data_from_import def base64_encode(raw_data: str | bytes, wrap_raw_data: bool = True) -> str: @@ -37,6 +37,8 @@ def base64_decode( encoded_data: str, unwrap_raw_data: bool = True, encoding: str = "yaml", + *, + as_extended: bool = True, ) -> Any: """Decodes data from base64 format. @@ -44,6 +46,7 @@ def base64_decode( encoded_data (str): The base64 encoded string to decode. unwrap_raw_data (bool): Whether to unwrap the raw data after decoding. encoding (str): The encoding format used for wrapping (default is 'yaml'). + as_extended (bool): Wrap decoded values in Tier 2 Extended Data containers. Returns: Any: The decoded bytes when ``unwrap_raw_data`` is ``False``, otherwise @@ -59,4 +62,4 @@ def base64_decode( message = "Decoded Base64 payload is not valid UTF-8 text." raise ValueError(message) from exc - return unwrap_raw_data_from_import(decoded_text, encoding=encoding) + return unwrap_raw_data_from_import(decoded_text, encoding=encoding, as_extended=as_extended) diff --git a/src/extended_data/export_utils.py b/src/extended_data/io/exporters.py similarity index 83% rename from src/extended_data/export_utils.py rename to src/extended_data/io/exporters.py index f612ef5..dc838eb 100644 --- a/src/extended_data/export_utils.py +++ b/src/extended_data/io/exporters.py @@ -8,18 +8,20 @@ from collections.abc import Mapping from typing import Any -from extended_data.hcl2_utils import encode_hcl2 -from extended_data.json_utils import encode_json -from extended_data.serialization_utils import normalize_data_encoding -from extended_data.toml_utils import encode_toml -from extended_data.type_utils import convert_special_types, strtobool -from extended_data.yaml_utils import ( +from extended_data.containers.factory import to_builtin +from extended_data.primitives.formats.hcl import encode_hcl2 +from extended_data.primitives.formats.json import encode_json +from extended_data.primitives.formats.toml import encode_toml +from extended_data.primitives.formats.yaml import ( LiteralScalarString, YamlPairs, YamlTagged, encode_yaml, is_yaml_data, ) +from extended_data.primitives.redaction import redact_sensitive_text +from extended_data.primitives.serialization import normalize_data_encoding +from extended_data.primitives.types import convert_special_types, string_to_bool def wrap_raw_data_for_export( @@ -40,14 +42,15 @@ def wrap_raw_data_for_export( Raises: ValueError: If an invalid or unsupported encoding is provided. """ - contains_yaml_data = is_yaml_data(raw_data) - converted_data = convert_special_types(raw_data) + export_data = to_builtin(raw_data) + contains_yaml_data = is_yaml_data(export_data) + converted_data = convert_special_types(export_data) # Check if allow_encoding is a string specifying the format if isinstance(allow_encoding, str): allow_encoding_lower = normalize_data_encoding(allow_encoding) if allow_encoding_lower == "yaml": - return encode_yaml(make_raw_data_export_safe(raw_data, export_to_yaml=True)) + return encode_yaml(make_raw_data_export_safe(export_data, export_to_yaml=True)) if allow_encoding_lower == "json": return encode_json(converted_data, **format_opts) if allow_encoding_lower == "toml": @@ -59,15 +62,15 @@ def wrap_raw_data_for_export( # Attempt to convert string-based allow_encoding to a boolean try: - allow_encoding_bool = strtobool(allow_encoding, raise_on_error=True) + allow_encoding_bool = string_to_bool(allow_encoding, raise_on_error=True) allow_encoding = allow_encoding_bool if isinstance(allow_encoding_bool, bool) else allow_encoding except ValueError as e: - raise ValueError(f"Invalid allow_encoding value: {allow_encoding}") from e + raise ValueError(f"Invalid allow_encoding value: {redact_sensitive_text(allow_encoding)}") from e # Determine the encoding based on boolean allow_encoding and YAML data check if allow_encoding: if contains_yaml_data: - return encode_yaml(make_raw_data_export_safe(raw_data, export_to_yaml=True)) + return encode_yaml(make_raw_data_export_safe(export_data, export_to_yaml=True)) # Call encode_json with options unpacked to ensure they are correctly passed return encode_json(converted_data, **format_opts) @@ -103,6 +106,8 @@ def make_raw_data_export_safe(raw_data: Any, export_to_yaml: bool = False) -> An >>> type(result["script"]).__name__ 'LiteralScalarString' """ + raw_data = to_builtin(raw_data) + if export_to_yaml and isinstance(raw_data, YamlTagged): return YamlTagged( raw_data.tag, diff --git a/src/extended_data/file_data_type.py b/src/extended_data/io/files.py similarity index 61% rename from src/extended_data/file_data_type.py rename to src/extended_data/io/files.py index ce28915..723684f 100644 --- a/src/extended_data/file_data_type.py +++ b/src/extended_data/io/files.py @@ -6,21 +6,210 @@ import tempfile import urllib.request +from base64 import b64encode from collections.abc import Mapping +from copy import deepcopy +from dataclasses import dataclass, field from pathlib import Path -from typing import Any, TypeAlias +from typing import TYPE_CHECKING, Any, TypeAlias, cast import validators from git import GitCommandError, InvalidGitRepositoryError, NoSuchPathError, Repo -from extended_data.serialization_utils import normalize_data_encoding +from extended_data.containers import ExtendedDict, ExtendedString, extend_data, to_builtin +from extended_data.io.exporters import make_raw_data_export_safe, wrap_raw_data_for_export +from extended_data.primitives.redaction import redact_sensitive_data, redact_sensitive_text +from extended_data.primitives.serialization import normalize_data_encoding + + +if TYPE_CHECKING: + from extended_data.workflows import DataWorkflow FilePath: TypeAlias = str | os.PathLike[str] """Type alias for file paths that can be represented as strings or os.PathLike objects.""" +@dataclass(frozen=True, slots=True) +class DataFile: + """Decoded file or URL data with source metadata and export helpers.""" + + source: ExtendedString + data: Any + encoding: ExtendedString + path: Path | None = None + metadata: ExtendedDict = field(default_factory=ExtendedDict) + + @classmethod + def decode( + cls, + file_data: str | memoryview | bytes | bytearray, + *, + file_path: FilePath | None = None, + suffix: str | None = None, + as_extended: bool = True, + metadata: Mapping[str, Any] | None = None, + ) -> DataFile: + """Decode in-memory data into a first-class data file artifact.""" + encoding = _resolve_data_file_encoding(file_path=file_path, suffix=suffix) + decoded = decode_file(file_data, file_path=file_path, suffix=encoding, as_extended=as_extended) + source = str(file_path) if file_path is not None else "memory" + return cls( + source=ExtendedString(_safe_data_file_source(source)), + data=decoded, + encoding=ExtendedString(encoding), + metadata=_data_file_metadata(source=source, encoding=encoding, path=None, data=decoded, extra=metadata), + ) + + @classmethod + def read( + cls, + file_path: FilePath, + *, + suffix: str | None = None, + as_extended: bool = True, + charset: str = "utf-8", + errors: str = "strict", + headers: Mapping[str, str] | None = None, + tld: Path | None = None, + ) -> DataFile: + """Read and decode a local file or URL into a first-class data artifact.""" + file_data = read_file( + file_path, + charset=charset, + errors=errors, + headers=headers, + tld=tld, + ) + if file_data is None: + raise FileNotFoundError(str(file_path)) + + source = str(file_path) + encoding = _resolve_data_file_encoding(file_path=file_path, suffix=suffix) + decoded = decode_file( + cast(str | memoryview | bytes | bytearray, file_data), + file_path=file_path, + suffix=encoding, + as_extended=as_extended, + ) + path = None if is_url(source) else resolve_local_path(file_path, tld=tld) + return cls( + source=ExtendedString(_safe_data_file_source(source)), + data=decoded, + encoding=ExtendedString(encoding), + path=path, + metadata=_data_file_metadata(source=source, encoding=encoding, path=path, data=decoded), + ) + + def as_builtin(self) -> Any: + """Return the artifact data lowered to built-in Python values.""" + return to_builtin(self.data) + + def as_extended(self) -> Any: + """Return a detached copy of artifact data promoted to Extended Data containers.""" + return extend_data(deepcopy(to_builtin(self.data))) + + def to_export_safe(self, *, export_to_yaml: bool = False) -> Any: + """Return the artifact data converted to export-safe primitive values.""" + return make_raw_data_export_safe(self.data, export_to_yaml=export_to_yaml) + + def wrap_for_export(self, allow_encoding: bool | str = True, **format_opts: Any) -> str: + """Return the artifact data wrapped as an encoded export string.""" + return wrap_raw_data_for_export(self.data, allow_encoding=allow_encoding, **format_opts) + + def workflow(self, *, as_extended: bool = True) -> DataWorkflow: + """Start a DataWorkflow from this artifact's decoded data.""" + from extended_data.workflows import DataWorkflow + + return DataWorkflow.from_data_file(self, as_extended=as_extended) + + def write( + self, + file_path: FilePath | None = None, + *, + encoding: str | None = None, + charset: str = "utf-8", + allow_empty: bool = False, + tld: Path | None = None, + ) -> DataFile: + """Write artifact data and return a new artifact for the output path.""" + target = file_path if file_path is not None else self.path + if target is None: + raise ValueError("DataFile has no local path; pass file_path to write it") + + output_path = write_file( + target, + self.data, + encoding=encoding, + charset=charset, + allow_empty=allow_empty, + tld=tld, + ) + if output_path is None: + raise ValueError("DataFile data was empty; pass allow_empty=True to write it") + + output_encoding = _resolve_data_file_encoding(file_path=output_path, suffix=encoding) + return DataFile( + source=ExtendedString(_safe_data_file_source(str(target))), + data=self.data, + encoding=ExtendedString(output_encoding), + path=output_path, + metadata=_data_file_metadata(source=str(target), encoding=output_encoding, path=output_path, data=self.data), + ) + + +def _resolve_data_file_encoding(*, file_path: FilePath | None = None, suffix: str | None = None) -> str: + """Return the normalized encoding used by a DataFile artifact.""" + if suffix is not None: + return normalize_data_encoding(suffix) or "raw" + if file_path is not None: + return get_encoding_for_file_path(file_path) + return "raw" + + +def _safe_data_file_source(source: str) -> str: + """Return a source label safe for metadata and workflow steps.""" + return redact_sensitive_text(source) + + +def _data_file_metadata( + *, + source: str, + encoding: str, + path: Path | None, + data: Any, + extra: Mapping[str, Any] | None = None, +) -> ExtendedDict: + """Return promoted artifact metadata for workflow and connector handoff.""" + metadata = ExtendedDict(redact_sensitive_data(extra or {})) + metadata.update( + { + "source": _safe_data_file_source(source), + "encoding": encoding, + "path": redact_sensitive_text(path) if path is not None else None, + "is_url": is_url(source), + "data_type": type(data).__name__, + } + ) + return metadata + + +def _github_auth_header_env(github_token: str) -> dict[str, str]: + """Return Git environment config for GitHub token auth without URL credentials.""" + env = os.environ.copy() + try: + config_count = int(env.get("GIT_CONFIG_COUNT", "0")) + except ValueError: + config_count = 0 + + encoded = b64encode(f"x-access-token:{github_token}".encode()).decode("ascii") + env[f"GIT_CONFIG_KEY_{config_count}"] = "http.https://github.com/.extraheader" + env[f"GIT_CONFIG_VALUE_{config_count}"] = f"Authorization: Basic {encoded}" + env["GIT_CONFIG_COUNT"] = str(config_count + 1) + return env + + def get_parent_repository(file_path: FilePath | None = None, search_parent_directories: bool = True) -> Repo | None: """Retrieves the Git repository object for a given path. @@ -74,11 +263,11 @@ def clone_repository_to_temp( Raises: EnvironmentError: If errors occur while trying to clone a Git repository. """ - repo_url = f"https://{github_token}:x-oauth-basic@github.com/{repo_owner}/{repo_name}.git" + repo_url = f"https://github.com/{repo_owner}/{repo_name}.git" try: temp_dir = Path(tempfile.mkdtemp()) - repo = Repo.clone_from(repo_url, str(temp_dir), branch=branch or None) + repo = Repo.clone_from(repo_url, str(temp_dir), branch=branch or None, env=_github_auth_header_env(github_token)) return temp_dir, repo except GitCommandError as e: error_message = "Git command error occurred" @@ -299,6 +488,8 @@ def decode_file( file_data: str | memoryview | bytes | bytearray, file_path: FilePath | None = None, suffix: str | None = None, + *, + as_extended: bool = True, ) -> Any: """Decodes file data based on file extension or explicit suffix. @@ -310,12 +501,13 @@ def decode_file( file_path (FilePath | None): Optional file path to infer format from extension. suffix (str | None): Explicit format suffix (e.g., "yaml", "json", "toml", "hcl"). Takes precedence over file_path extension. + as_extended (bool): Wrap decoded values in Tier 2 Extended Data containers. Returns: Any: The decoded data structure, or the original string if format is unknown. """ # Lazy imports to avoid circular dependencies - from extended_data.import_utils import unwrap_raw_data_from_import + from extended_data.io.importers import unwrap_raw_data_from_import if suffix is None and file_path is not None: suffix = get_encoding_for_file_path(file_path) @@ -323,10 +515,44 @@ def decode_file( suffix = normalize_data_encoding(suffix) if suffix is not None and suffix in {"yaml", "json", "toml", "hcl", "raw"}: - return unwrap_raw_data_from_import(file_data, encoding=suffix) + return unwrap_raw_data_from_import(file_data, encoding=suffix, as_extended=as_extended) return file_data +def read_data_file( + file_path: FilePath, + *, + suffix: str | None = None, + as_extended: bool = True, + charset: str = "utf-8", + errors: str = "strict", + headers: Mapping[str, str] | None = None, + tld: Path | None = None, +) -> Any: + """Read and decode a local file or URL through the Tier 3 data boundary. + + This composes ``read_file`` and ``decode_file`` for the common data-file + workflow. Structured files are decoded from their suffix and promoted to + Tier 2 containers by default. Missing local files fail loudly. + """ + file_data = read_file( + file_path, + charset=charset, + errors=errors, + headers=headers, + tld=tld, + ) + if file_data is None: + raise FileNotFoundError(str(file_path)) + + return decode_file( + cast(str | memoryview | bytes | bytearray, file_data), + file_path=file_path, + suffix=suffix, + as_extended=as_extended, + ) + + def write_file( file_path: FilePath, data: Any, @@ -349,8 +575,8 @@ def write_file( Returns: Path | None: The path that was written to, or None if data was empty and not allowed. """ - from extended_data.export_utils import wrap_raw_data_for_export - from extended_data.state_utils import is_nothing + from extended_data.io.exporters import wrap_raw_data_for_export + from extended_data.primitives.state import is_nothing if is_nothing(data) and not allow_empty: return None diff --git a/src/extended_data/io/importers.py b/src/extended_data/io/importers.py new file mode 100644 index 0000000..5c362c6 --- /dev/null +++ b/src/extended_data/io/importers.py @@ -0,0 +1,53 @@ +"""This module provides utilities for unwrapping data after import.""" + +from __future__ import annotations + +from typing import Any + +from extended_data.containers.factory import extend_data +from extended_data.primitives.formats.hcl import decode_hcl2 +from extended_data.primitives.formats.json import decode_json +from extended_data.primitives.formats.toml import decode_toml +from extended_data.primitives.formats.yaml import decode_yaml +from extended_data.primitives.serialization import normalize_data_encoding +from extended_data.primitives.strings import bytes_to_string + + +def unwrap_raw_data_from_import( + wrapped_data: str | memoryview | bytes | bytearray, + encoding: str = "yaml", + *, + as_extended: bool = True, +) -> Any: + """Unwraps the data that was wrapped for import. + + Args: + wrapped_data (str | memoryview | bytes | bytearray): The wrapped data. + encoding (str): The encoding format (default is 'yaml'). + as_extended (bool): Wrap decoded values in Tier 2 Extended Data containers. + + Returns: + Any: The unwrapped data. + + Raises: + ValueError: If the encoding format is unsupported. + """ + normalized_encoding = normalize_data_encoding(encoding) + + if normalized_encoding == "yaml": + decoded = decode_yaml(wrapped_data) + elif normalized_encoding == "json": + decoded = decode_json(wrapped_data) + elif normalized_encoding == "toml": + decoded = decode_toml(wrapped_data) + elif normalized_encoding == "hcl": + decoded = decode_hcl2(wrapped_data) + elif normalized_encoding == "raw": + decoded = bytes_to_string(wrapped_data) + else: + error_message = f"Unsupported encoding format: {encoding}" + raise ValueError(error_message) + + if as_extended: + return extend_data(decoded) + return decoded diff --git a/src/extended_data/logging/logging.py b/src/extended_data/logging/logging.py index 9ad47dd..fb84b6e 100644 --- a/src/extended_data/logging/logging.py +++ b/src/extended_data/logging/logging.py @@ -18,7 +18,6 @@ import os import sys -from collections import defaultdict from collections.abc import Callable, Mapping, Sequence from copy import deepcopy from pathlib import Path @@ -28,18 +27,8 @@ cast, ) -import orjson - -from extended_data import ( - get_unique_signature, - is_nothing, - strtobool, - to_camel_case, - to_kebab_case, - to_pascal_case, - to_snake_case, - wrap_raw_data_for_export, -) +from extended_data.containers import ExtendedDict, ExtendedSet, to_builtin +from extended_data.io import wrap_raw_data_for_export from extended_data.logging.const import VERBOSITY from extended_data.logging.handlers import add_console_handler, add_file_handler from extended_data.logging.log_types import LogLevel @@ -49,6 +38,17 @@ find_logger, get_log_level, ) +from extended_data.primitives import ( + get_unique_signature, + is_nothing, + redact_sensitive_data, + redact_sensitive_text, + string_to_bool, + to_camel_case, + to_kebab_case, + to_pascal_case, + to_snake_case, +) # Type alias for key transformation functions @@ -73,7 +73,7 @@ class Logging: def __init__( self, enable_console: bool = False, - enable_file: bool = True, + enable_file: bool = False, logger: logging.Logger | None = None, logger_name: str | None = None, log_file_name: str | None = None, @@ -91,7 +91,8 @@ def __init__( Args: enable_console: Whether to enable console output. - enable_file: Whether to enable file output. + enable_file: Whether to enable file output. Defaults to False so library + consumers do not get log files unless they opt in. logger: An existing logger instance to use. logger_name: The name for a new logger instance. log_file_name: The name of the log file if file logging enabled. @@ -117,7 +118,7 @@ def __init__( ) # Message storage - self.stored_messages: defaultdict[str, set[str]] = defaultdict(set) + self.stored_messages: ExtendedDict = ExtendedDict() self.error_list: list[str] = [] self.last_error_instance: Any = None self.last_error_text: str | None = None @@ -193,10 +194,10 @@ def _setup_handlers(self, logger: logging.Logger, log_file_name: str) -> None: logger.setLevel(gunicorn_logger.level) return - if self.enable_console or strtobool(os.getenv("OVERRIDE_TO_CONSOLE", "False")): + if self.enable_console or string_to_bool(os.getenv("OVERRIDE_TO_CONSOLE", "False")): add_console_handler(logger) - if self.enable_file or strtobool(os.getenv("OVERRIDE_TO_FILE", "False")): + if self.enable_file or string_to_bool(os.getenv("OVERRIDE_TO_FILE", "False")): # Pass the log file name directly add_file_handler(logger, log_file_name) @@ -280,10 +281,28 @@ def _store_logged_message( return if (not allowed_levels or log_level in allowed_levels) and log_level not in denied_levels: - self.stored_messages[storage_marker].add( + self._stored_messages_for(storage_marker).add( f":warning: {msg}" if log_level not in ["debug", "info"] else msg, ) + def _stored_messages_for(self, storage_marker: str) -> ExtendedSet[str]: + """Return the promoted message collection for a storage marker.""" + stored_messages = self.stored_messages.get(storage_marker) + if isinstance(stored_messages, ExtendedSet): + return stored_messages + + promoted_messages = ExtendedSet[str](stored_messages or []) + self.stored_messages[storage_marker] = promoted_messages + return promoted_messages + + def get_stored_messages(self, storage_marker: str) -> ExtendedSet[str]: + """Return a detached promoted copy of messages for one storage marker.""" + return ExtendedSet[str](deepcopy(to_builtin(self.stored_messages.get(storage_marker, ExtendedSet())))) + + def snapshot_stored_messages(self) -> ExtendedDict: + """Return a detached Tier 2 snapshot of all stored message collections.""" + return ExtendedDict(deepcopy(to_builtin(self.stored_messages))) + def logged_statement( self, msg: str, @@ -323,6 +342,7 @@ def logged_statement( final_msg = self._prepare_message(msg, context_marker, identifiers) final_msg = add_json_data(final_msg, json_data, labeled_json_data) + final_msg = redact_sensitive_text(final_msg) # Normalize levels once here before passing to storage final_allowed = self._normalize_levels(allowed_levels) if allowed_levels is not None else self.allowed_levels @@ -383,14 +403,12 @@ def log_results( def _resolve_key_transform( self, key_transform: KeyTransform | str | None, - unhump_results: bool, prefix: str | None, ) -> KeyTransform | None: """Resolve key_transform parameter to a callable. Args: key_transform: User-provided transform (callable, string name, or None). - unhump_results: Legacy flag for snake_case transformation. prefix: If set, implies transformation is needed. Returns: @@ -406,8 +424,7 @@ def _resolve_key_transform( raise ValueError(f"Unknown key_transform '{key_transform}'. Available: {available}") return self.KEY_TRANSFORMS[key_transform] - # Legacy unhump_results flag - if unhump_results or prefix: + if prefix: return to_snake_case return None @@ -440,10 +457,18 @@ def _transform_nested_keys( result[transformed_key] = value return result + @staticmethod + def _format_exit_run_error_snapshot(data: Any) -> str: + """Return a redacted diagnostic snapshot for exit_run failures.""" + redacted_data = redact_sensitive_data(data) + try: + return wrap_raw_data_for_export(redacted_data, allow_encoding=True) + except Exception: + return redact_sensitive_text(redacted_data) + def exit_run( self, results: Mapping[str, Any] | None = None, - unhump_results: bool = False, key_transform: KeyTransform | str | None = None, prefix: str | None = None, prefix_allowlist: Sequence[str] | None = None, @@ -469,14 +494,11 @@ def exit_run( Args: results: The results to format and output. Defaults to empty dict. - unhump_results: Convert camelCase keys to snake_case (shorthand for - key_transform="snake_case"). key_transform: Transform function for result keys. Can be: - A callable that takes a string and returns a string - A string naming a built-in transform: "snake_case", "camel_case", "pascal_case", "kebab_case" - None to skip transformation - When unhump_results=True, defaults to "snake_case". prefix: Prefix to add to result keys (implies key transformation). prefix_allowlist: Keys to include when prefixing. prefix_denylist: Keys to exclude when prefixing. @@ -500,7 +522,7 @@ def exit_run( Examples: # Simple snake_case transformation (most common) - logging.exit_run(results, unhump_results=True) + logging.exit_run(results, key_transform="snake_case") # Explicit transform logging.exit_run(results, key_transform="kebab_case") @@ -508,8 +530,11 @@ def exit_run( # Custom transform function logging.exit_run(results, key_transform=lambda k: k.upper()) """ + if "unhump_results" in format_opts: + raise TypeError("exit_run() got an unexpected keyword argument 'unhump_results'") + # Resolve key_transform from various inputs - transform_fn = self._resolve_key_transform(key_transform, unhump_results, prefix) + transform_fn = self._resolve_key_transform(key_transform, prefix) try: self.log_results(results, "results") @@ -625,11 +650,14 @@ def encode_result_with_base64(r: Any) -> str: if not isinstance(data, str): self.logger.info("Dumping results to JSON") - data = orjson.dumps(data, default=str).decode("utf-8") + data = wrap_raw_data_for_export(data, allow_encoding="json", default=str) sys.stdout.write(data) sys.exit(0) - except ExitRunError as exc: - err_msg = f"Failed to dump results because of a formatting error:\n\n{data}" - self.logger.critical(err_msg, exc_info=True) - raise RuntimeError(err_msg) from exc + except ExitRunError: + err_msg = ( + "Failed to dump results because of a formatting error:\n\n" + f"{self._format_exit_run_error_snapshot(data)}" + ) + self.logger.critical(err_msg) + raise RuntimeError(err_msg) from None diff --git a/src/extended_data/logging/utils.py b/src/extended_data/logging/utils.py index 3843e8c..e84bbba 100644 --- a/src/extended_data/logging/utils.py +++ b/src/extended_data/logging/utils.py @@ -1,4 +1,4 @@ -"""Utility helpers for LifecycleLogging internals.""" +"""Utility helpers for structured logging internals.""" from __future__ import annotations @@ -8,8 +8,9 @@ from copy import copy, deepcopy from typing import Any -from extended_data import make_raw_data_export_safe, wrap_raw_data_for_export +from extended_data.io import make_raw_data_export_safe, wrap_raw_data_for_export from extended_data.logging.const import DEFAULT_LOG_LEVEL +from extended_data.primitives.redaction import redact_sensitive_data def get_log_level(level: int | str) -> int: @@ -87,7 +88,7 @@ def sanitize_json_data(data: Any) -> Any: """ # Use Extended Data core' make_raw_data_export_safe for comprehensive handling # This handles datetime, Path, large numbers, and more - return make_raw_data_export_safe(data, export_to_yaml=False) + return redact_sensitive_data(make_raw_data_export_safe(data, export_to_yaml=False)) def add_labeled_json( diff --git a/src/extended_data/primitives/__init__.py b/src/extended_data/primitives/__init__.py new file mode 100644 index 0000000..340fedf --- /dev/null +++ b/src/extended_data/primitives/__init__.py @@ -0,0 +1,172 @@ +"""Tier 1 pure data primitives.""" + +from extended_data.primitives.formats import ( + decode_hcl2, + decode_json, + decode_toml, + decode_yaml, + encode_hcl2, + encode_json, + encode_toml, + encode_yaml, + is_yaml_data, +) +from extended_data.primitives.introspection import ( + filter_methods, + get_available_methods, + get_caller, + get_inputs_from_docstring, + get_unique_signature, + update_docstring, +) +from extended_data.primitives.mappings import ( + all_values_from_map, + create_merger, + deduplicate_map, + deep_merge, + filter_map, + first_non_empty_value_from_map, + flatten_map, + get_default_dict, + unhump_map, + zipmap, +) +from extended_data.primitives.matching import is_non_empty_match, is_partial_match +from extended_data.primitives.numbers import ( + from_roman, + number_to_currency, + number_to_ordinal, + number_to_words, + to_roman, +) +from extended_data.primitives.redaction import redact_sensitive_data, redact_sensitive_text +from extended_data.primitives.sequences import filter_list, flatten_list +from extended_data.primitives.serialization import normalize_data_encoding +from extended_data.primitives.splitting import split_dict_by_type, split_list_by_type +from extended_data.primitives.state import ( + all_non_empty, + all_non_empty_in_dict, + all_non_empty_in_list, + any_non_empty, + are_nothing, + first_non_empty, + is_nothing, + yield_non_empty, +) +from extended_data.primitives.string_transforms import ( + humanize, + ordinalize, + pluralize, + singularize, + titleize, + to_camel_case, + to_kebab_case, + to_pascal_case, + to_snake_case, +) +from extended_data.primitives.strings import ( + bytes_to_string, + lower_first_char, + sanitize_key, + titleize_name, + truncate, + upper_first_char, +) +from extended_data.primitives.types import ( + convert_special_type, + convert_special_types, + get_default_value_for_type, + get_primitive_type_for_instance_type, + make_hashable, + reconstruct_special_type, + reconstruct_special_types, + string_to_bool, + string_to_date, + string_to_datetime, + string_to_float, + string_to_int, + string_to_path, + string_to_time, + typeof, +) + + +__all__ = [ + "all_non_empty", + "all_non_empty_in_dict", + "all_non_empty_in_list", + "all_values_from_map", + "any_non_empty", + "are_nothing", + "bytes_to_string", + "convert_special_type", + "convert_special_types", + "create_merger", + "decode_hcl2", + "decode_json", + "decode_toml", + "decode_yaml", + "deduplicate_map", + "deep_merge", + "encode_hcl2", + "encode_json", + "encode_toml", + "encode_yaml", + "filter_list", + "filter_map", + "filter_methods", + "first_non_empty", + "first_non_empty_value_from_map", + "flatten_list", + "flatten_map", + "from_roman", + "get_available_methods", + "get_caller", + "get_default_dict", + "get_default_value_for_type", + "get_inputs_from_docstring", + "get_primitive_type_for_instance_type", + "get_unique_signature", + "humanize", + "is_non_empty_match", + "is_nothing", + "is_partial_match", + "is_yaml_data", + "lower_first_char", + "make_hashable", + "normalize_data_encoding", + "number_to_currency", + "number_to_ordinal", + "number_to_words", + "ordinalize", + "pluralize", + "reconstruct_special_type", + "reconstruct_special_types", + "redact_sensitive_data", + "redact_sensitive_text", + "sanitize_key", + "singularize", + "split_dict_by_type", + "split_list_by_type", + "string_to_bool", + "string_to_date", + "string_to_datetime", + "string_to_float", + "string_to_int", + "string_to_path", + "string_to_time", + "titleize", + "titleize_name", + "to_camel_case", + "to_kebab_case", + "to_pascal_case", + "to_roman", + "to_snake_case", + "truncate", + "typeof", + "unhump_map", + "update_docstring", + "upper_first_char", + "yield_non_empty", + "zipmap", +] diff --git a/src/extended_data/primitives/formats/__init__.py b/src/extended_data/primitives/formats/__init__.py new file mode 100644 index 0000000..29a0df8 --- /dev/null +++ b/src/extended_data/primitives/formats/__init__.py @@ -0,0 +1,47 @@ +"""Tier 1 serialization codecs.""" + +from extended_data.primitives.formats.errors import DataDecodeError +from extended_data.primitives.formats.hcl import decode_hcl2, encode_hcl2 +from extended_data.primitives.formats.json import decode_json, encode_json +from extended_data.primitives.formats.toml import decode_toml, encode_toml +from extended_data.primitives.formats.yaml import ( + LiteralScalarString, + PureDumper, + PureLoader, + YamlPairs, + YamlTagged, + decode_yaml, + encode_yaml, + is_yaml_data, + yaml_construct_pairs, + yaml_construct_undefined, + yaml_literal_str_representer, + yaml_represent_pairs, + yaml_represent_tagged, + yaml_str_representer, +) + + +__all__ = [ + "DataDecodeError", + "LiteralScalarString", + "PureDumper", + "PureLoader", + "YamlPairs", + "YamlTagged", + "decode_hcl2", + "decode_json", + "decode_toml", + "decode_yaml", + "encode_hcl2", + "encode_json", + "encode_toml", + "encode_yaml", + "is_yaml_data", + "yaml_construct_pairs", + "yaml_construct_undefined", + "yaml_literal_str_representer", + "yaml_represent_pairs", + "yaml_represent_tagged", + "yaml_str_representer", +] diff --git a/src/extended_data/primitives/formats/_normalization.py b/src/extended_data/primitives/formats/_normalization.py new file mode 100644 index 0000000..b9b19e7 --- /dev/null +++ b/src/extended_data/primitives/formats/_normalization.py @@ -0,0 +1,15 @@ +"""Internal helpers for normalizing data before format encoding.""" + +from __future__ import annotations + +from typing import Any + + +def lower_extended_data(value: Any) -> Any: + """Lower Tier 2 containers to plain values before handing data to codecs.""" + from extended_data.containers.factory import to_builtin + + return to_builtin(value) + + +__all__ = ["lower_extended_data"] diff --git a/src/extended_data/primitives/formats/errors.py b/src/extended_data/primitives/formats/errors.py new file mode 100644 index 0000000..4c33cdd --- /dev/null +++ b/src/extended_data/primitives/formats/errors.py @@ -0,0 +1,72 @@ +"""Shared exceptions for Tier 1 format decoders.""" + +from __future__ import annotations + +from typing import Any + + +class DataDecodeError(ValueError): + """Raised when a supported data format cannot be decoded safely.""" + + def __init__( + self, + format_name: str, + *, + reason: str | None = None, + line: int | None = None, + column: int | None = None, + ) -> None: + """Initialize a sanitized decode error.""" + self.format_name = format_name + self.reason = reason + self.line = line + self.column = column + + message = f"Failed to decode {format_name} data" + if reason: + message = f"{message}: {reason}" + if line is not None: + message = f"{message} at line {line}" + if column is not None: + message = f"{message}, column {column}" + super().__init__(f"{message}.") + + @classmethod + def from_exception(cls, format_name: str, exc: BaseException) -> DataDecodeError: + """Build a sanitized decode error from a parser exception.""" + line, column = _get_error_position(exc) + return cls(format_name, reason=_get_error_reason(exc), line=line, column=column) + + +def invalid_utf8_error(format_name: str) -> DataDecodeError: + """Return a decode error for invalid UTF-8 input bytes.""" + return DataDecodeError(format_name, reason="input bytes are not valid UTF-8") + + +def _get_error_reason(exc: BaseException) -> str: + """Extract a parser reason without including source snippets.""" + for attr in ("msg", "problem"): + value = getattr(exc, attr, None) + if isinstance(value, str) and value: + return value.strip().replace("\n", " ") + return type(exc).__name__ + + +def _get_error_position(exc: BaseException) -> tuple[int | None, int | None]: + """Extract one-based line and column data when the parser exposes it.""" + line = _as_int(getattr(exc, "lineno", None) or getattr(exc, "line", None)) + column = _as_int(getattr(exc, "colno", None) or getattr(exc, "col", None) or getattr(exc, "column", None)) + + mark = getattr(exc, "problem_mark", None) + if mark is not None: + line = _as_int(getattr(mark, "line", None), offset=1) + column = _as_int(getattr(mark, "column", None), offset=1) + + return line, column + + +def _as_int(value: Any, *, offset: int = 0) -> int | None: + """Return an integer value plus offset, or None when unavailable.""" + if isinstance(value, int): + return value + offset + return None diff --git a/src/extended_data/hcl2_utils.py b/src/extended_data/primitives/formats/hcl.py similarity index 91% rename from src/extended_data/hcl2_utils.py rename to src/extended_data/primitives/formats/hcl.py index 1eb1a86..340a9cc 100644 --- a/src/extended_data/hcl2_utils.py +++ b/src/extended_data/primitives/formats/hcl.py @@ -11,10 +11,12 @@ import hcl2 -from lark.exceptions import ParseError +from lark.exceptions import LarkError -from extended_data.string_data_type import bytestostr -from extended_data.type_utils import convert_special_types +from extended_data.primitives.formats._normalization import lower_extended_data +from extended_data.primitives.formats.errors import DataDecodeError, invalid_utf8_error +from extended_data.primitives.strings import bytes_to_string +from extended_data.primitives.types import convert_special_types _HCL_METADATA_KEYS = frozenset({"__is_block__"}) @@ -233,12 +235,15 @@ def decode_hcl2(hcl2_data: str | memoryview | bytes | bytearray) -> Any: UnexpectedToken If the HCL2 data cannot be parsed. """ try: - hcl2_data = bytestostr(hcl2_data) + hcl2_data = bytes_to_string(hcl2_data) except UnicodeDecodeError as exc: - raise ParseError(f"Failed to decode bytes to string: {hcl2_data!r}") from exc + raise invalid_utf8_error("HCL2") from exc hcl2_data_stream = StringIO(hcl2_data) - return _normalize_hcl_value(hcl2.load(hcl2_data_stream)) + try: + return _normalize_hcl_value(hcl2.load(hcl2_data_stream)) + except LarkError as exc: + raise DataDecodeError.from_exception("HCL2", exc) from exc def encode_hcl2(data: Any) -> str: @@ -250,9 +255,10 @@ def encode_hcl2(data: Any) -> str: Returns: str: The encoded HCL2 string. """ - if not isinstance(data, Mapping): + normalized_data = lower_extended_data(data) + if not isinstance(normalized_data, Mapping): message = "HCL encoding requires a mapping at the document root." raise TypeError(message) - serialized = _serialize_hcl_body(convert_special_types(data), indent_level=0) + serialized = _serialize_hcl_body(convert_special_types(normalized_data), indent_level=0) return serialized.rstrip() diff --git a/src/extended_data/json_utils.py b/src/extended_data/primitives/formats/json.py similarity index 88% rename from src/extended_data/json_utils.py rename to src/extended_data/primitives/formats/json.py index 815be5e..b1f6d7f 100644 --- a/src/extended_data/json_utils.py +++ b/src/extended_data/primitives/formats/json.py @@ -10,6 +10,9 @@ import orjson +from extended_data.primitives.formats._normalization import lower_extended_data +from extended_data.primitives.formats.errors import DataDecodeError + def decode_json(json_data: str | memoryview | bytes | bytearray) -> Any: """Decodes a JSON string or bytes into a Python object using orjson. @@ -20,7 +23,10 @@ def decode_json(json_data: str | memoryview | bytes | bytearray) -> Any: Returns: Any: The decoded Python object. """ - return orjson.loads(json_data) + try: + return orjson.loads(json_data) + except orjson.JSONDecodeError as exc: + raise DataDecodeError.from_exception("JSON", exc) from exc def encode_json( @@ -91,4 +97,4 @@ def encode_json( option |= orjson.OPT_APPEND_NEWLINE # Use orjson.dumps to encode the object with the calculated options - return orjson.dumps(raw_data, default=default, option=option).decode("utf-8") + return orjson.dumps(lower_extended_data(raw_data), default=default, option=option).decode("utf-8") diff --git a/src/extended_data/toml_utils.py b/src/extended_data/primitives/formats/toml.py similarity index 58% rename from src/extended_data/toml_utils.py rename to src/extended_data/primitives/formats/toml.py index 943444d..08f9676 100644 --- a/src/extended_data/toml_utils.py +++ b/src/extended_data/primitives/formats/toml.py @@ -9,10 +9,10 @@ import tomlkit -from tomlkit.exceptions import TOMLKitError - -from extended_data.string_data_type import bytestostr -from extended_data.type_utils import convert_special_types +from extended_data.primitives.formats._normalization import lower_extended_data +from extended_data.primitives.formats.errors import DataDecodeError, invalid_utf8_error +from extended_data.primitives.strings import bytes_to_string +from extended_data.primitives.types import convert_special_types def decode_toml(toml_data: str | memoryview | bytes | bytearray) -> Any: @@ -25,10 +25,13 @@ def decode_toml(toml_data: str | memoryview | bytes | bytearray) -> Any: Any: The decoded Python object with any special types processed. """ try: - toml_data = bytestostr(toml_data) + toml_data = bytes_to_string(toml_data) except UnicodeDecodeError as exc: - raise TOMLKitError(f"Failed to decode bytes to string: {toml_data!r}") from exc - return tomlkit.parse(toml_data) + raise invalid_utf8_error("TOML") from exc + try: + return tomlkit.parse(toml_data) + except tomlkit.exceptions.TOMLKitError as exc: + raise DataDecodeError.from_exception("TOML", exc) from exc def encode_toml(raw_data: Any) -> str: @@ -41,5 +44,5 @@ def encode_toml(raw_data: Any) -> str: str: The encoded TOML string. """ # Convert unsupported types to simpler forms before encoding - converted_data = convert_special_types(raw_data) + converted_data = convert_special_types(lower_extended_data(raw_data)) return tomlkit.dumps(converted_data) diff --git a/src/extended_data/yaml_utils/__init__.py b/src/extended_data/primitives/formats/yaml/__init__.py similarity index 64% rename from src/extended_data/yaml_utils/__init__.py rename to src/extended_data/primitives/formats/yaml/__init__.py index 1f3c07c..424cd48 100644 --- a/src/extended_data/yaml_utils/__init__.py +++ b/src/extended_data/primitives/formats/yaml/__init__.py @@ -5,24 +5,24 @@ from __future__ import annotations -from extended_data.yaml_utils.constructors import ( +from extended_data.primitives.formats.yaml.constructors import ( yaml_construct_pairs, yaml_construct_undefined, ) -from extended_data.yaml_utils.dumpers import PureDumper -from extended_data.yaml_utils.loaders import PureLoader -from extended_data.yaml_utils.representers import ( +from extended_data.primitives.formats.yaml.dumpers import PureDumper +from extended_data.primitives.formats.yaml.loaders import PureLoader +from extended_data.primitives.formats.yaml.representers import ( yaml_literal_str_representer, yaml_represent_pairs, yaml_represent_tagged, yaml_str_representer, ) -from extended_data.yaml_utils.tag_classes import ( +from extended_data.primitives.formats.yaml.tag_classes import ( LiteralScalarString, YamlPairs, YamlTagged, ) -from extended_data.yaml_utils.utils import decode_yaml, encode_yaml, is_yaml_data +from extended_data.primitives.formats.yaml.utils import decode_yaml, encode_yaml, is_yaml_data __all__ = [ diff --git a/src/extended_data/yaml_utils/constructors.py b/src/extended_data/primitives/formats/yaml/constructors.py similarity index 90% rename from src/extended_data/yaml_utils/constructors.py rename to src/extended_data/primitives/formats/yaml/constructors.py index af057fb..78337a4 100644 --- a/src/extended_data/yaml_utils/constructors.py +++ b/src/extended_data/primitives/formats/yaml/constructors.py @@ -9,7 +9,7 @@ from yaml import MappingNode, SafeLoader, ScalarNode, SequenceNode -from extended_data.yaml_utils.tag_classes import YamlPairs, YamlTagged +from extended_data.primitives.formats.yaml.tag_classes import YamlPairs, YamlTagged def yaml_construct_undefined( @@ -51,7 +51,7 @@ def yaml_construct_pairs( Returns: Union[Dict[Any, Any], YamlPairs]: The constructed YAML pairs. """ - value: list[tuple[Any, Any]] = loader.construct_pairs(node) # type: ignore[no-untyped-call] + value: list[tuple[Any, Any]] = loader.construct_pairs(node) try: return dict(value) except TypeError: diff --git a/src/extended_data/yaml_utils/dumpers.py b/src/extended_data/primitives/formats/yaml/dumpers.py similarity index 94% rename from src/extended_data/yaml_utils/dumpers.py rename to src/extended_data/primitives/formats/yaml/dumpers.py index acb4aaa..ac8acd8 100644 --- a/src/extended_data/yaml_utils/dumpers.py +++ b/src/extended_data/primitives/formats/yaml/dumpers.py @@ -13,13 +13,13 @@ from yaml import SafeDumper -from extended_data.yaml_utils.representers import ( +from extended_data.primitives.formats.yaml.representers import ( yaml_literal_str_representer, yaml_represent_pairs, yaml_represent_tagged, yaml_str_representer, ) -from extended_data.yaml_utils.tag_classes import ( +from extended_data.primitives.formats.yaml.tag_classes import ( LiteralScalarString, YamlPairs, YamlTagged, diff --git a/src/extended_data/yaml_utils/loaders.py b/src/extended_data/primitives/formats/yaml/loaders.py similarity index 93% rename from src/extended_data/yaml_utils/loaders.py rename to src/extended_data/primitives/formats/yaml/loaders.py index 756b9d7..eb49317 100644 --- a/src/extended_data/yaml_utils/loaders.py +++ b/src/extended_data/primitives/formats/yaml/loaders.py @@ -9,7 +9,7 @@ from yaml import SafeLoader -from extended_data.yaml_utils.constructors import ( +from extended_data.primitives.formats.yaml.constructors import ( yaml_construct_pairs, yaml_construct_undefined, ) diff --git a/src/extended_data/yaml_utils/representers.py b/src/extended_data/primitives/formats/yaml/representers.py similarity index 97% rename from src/extended_data/yaml_utils/representers.py rename to src/extended_data/primitives/formats/yaml/representers.py index 0691599..4c87c55 100644 --- a/src/extended_data/yaml_utils/representers.py +++ b/src/extended_data/primitives/formats/yaml/representers.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: from yaml import MappingNode, Node, SafeDumper, ScalarNode -from extended_data.yaml_utils.tag_classes import ( +from extended_data.primitives.formats.yaml.tag_classes import ( LiteralScalarString, YamlPairs, YamlTagged, diff --git a/src/extended_data/yaml_utils/tag_classes.py b/src/extended_data/primitives/formats/yaml/tag_classes.py similarity index 94% rename from src/extended_data/yaml_utils/tag_classes.py rename to src/extended_data/primitives/formats/yaml/tag_classes.py index e212de3..14c323b 100644 --- a/src/extended_data/yaml_utils/tag_classes.py +++ b/src/extended_data/primitives/formats/yaml/tag_classes.py @@ -13,12 +13,12 @@ if TYPE_CHECKING: from typing import TypeAlias - _ObjectProxyBase: TypeAlias = "wrapt.ObjectProxy[Any]" + _ObjectProxyBase: TypeAlias = wrapt.ObjectProxy[Any] else: _ObjectProxyBase = wrapt.ObjectProxy -class YamlTagged(_ObjectProxyBase): +class YamlTagged(_ObjectProxyBase): # type: ignore[misc] """Wrapper class for YAML tagged objects.""" def __init__(self, tag: str, wrapped: Any) -> None: diff --git a/src/extended_data/yaml_utils/utils.py b/src/extended_data/primitives/formats/yaml/utils.py similarity index 64% rename from src/extended_data/yaml_utils/utils.py rename to src/extended_data/primitives/formats/yaml/utils.py index 038a57f..446b099 100644 --- a/src/extended_data/yaml_utils/utils.py +++ b/src/extended_data/primitives/formats/yaml/utils.py @@ -10,10 +10,12 @@ import yaml -from extended_data.string_data_type import bytestostr -from extended_data.yaml_utils.dumpers import PureDumper -from extended_data.yaml_utils.loaders import PureLoader -from extended_data.yaml_utils.tag_classes import YamlPairs, YamlTagged +from extended_data.primitives.formats._normalization import lower_extended_data +from extended_data.primitives.formats.errors import DataDecodeError, invalid_utf8_error +from extended_data.primitives.formats.yaml.dumpers import PureDumper +from extended_data.primitives.formats.yaml.loaders import PureLoader +from extended_data.primitives.formats.yaml.tag_classes import YamlPairs, YamlTagged +from extended_data.primitives.strings import bytes_to_string def decode_yaml(yaml_data: str | memoryview | bytes | bytearray) -> Any: @@ -26,10 +28,13 @@ def decode_yaml(yaml_data: str | memoryview | bytes | bytearray) -> Any: Any: The decoded Python object. """ try: - yaml_data = bytestostr(yaml_data) + yaml_data = bytes_to_string(yaml_data) except UnicodeDecodeError as exc: - raise yaml.YAMLError(f"Failed to decode bytes to string: {yaml_data!r}") from exc - return yaml.load(yaml_data, Loader=PureLoader) # noqa: S506 + raise invalid_utf8_error("YAML") from exc + try: + return yaml.load(yaml_data, Loader=PureLoader) # noqa: S506 + except yaml.YAMLError as exc: + raise DataDecodeError.from_exception("YAML", exc) from exc def encode_yaml(raw_data: Any) -> str: @@ -41,7 +46,7 @@ def encode_yaml(raw_data: Any) -> str: Returns: str: The encoded YAML string. """ - return yaml.dump(raw_data, Dumper=PureDumper, allow_unicode=True, sort_keys=False) + return yaml.dump(lower_extended_data(raw_data), Dumper=PureDumper, allow_unicode=True, sort_keys=False) def is_yaml_data(data: Any) -> bool: diff --git a/src/extended_data/stack_utils.py b/src/extended_data/primitives/introspection.py similarity index 100% rename from src/extended_data/stack_utils.py rename to src/extended_data/primitives/introspection.py diff --git a/src/extended_data/map_data_type.py b/src/extended_data/primitives/mappings.py similarity index 98% rename from src/extended_data/map_data_type.py rename to src/extended_data/primitives/mappings.py index d78dad9..b5c609d 100644 --- a/src/extended_data/map_data_type.py +++ b/src/extended_data/primitives/mappings.py @@ -7,6 +7,7 @@ from __future__ import annotations import builtins +import copy from collections import defaultdict from collections.abc import Callable, Mapping, MutableMapping @@ -17,7 +18,7 @@ from deepmerge.merger import Merger from sortedcontainers import SortedDict -from extended_data.type_utils import convert_special_types +from extended_data.primitives.types import convert_special_types # Default merger configuration: @@ -59,7 +60,7 @@ def deep_merge(*mappings: Mapping[str, Any]) -> dict[str, Any]: result: dict[str, Any] = {} for mapping in mappings: if mapping: - result = _DEFAULT_MERGER.merge(result, dict(mapping)) + result = _DEFAULT_MERGER.merge(result, copy.deepcopy(dict(mapping))) return result diff --git a/src/extended_data/matcher_utils.py b/src/extended_data/primitives/matching.py similarity index 95% rename from src/extended_data/matcher_utils.py rename to src/extended_data/primitives/matching.py index 1d51671..10571e0 100644 --- a/src/extended_data/matcher_utils.py +++ b/src/extended_data/primitives/matching.py @@ -9,8 +9,8 @@ from collections.abc import Mapping from typing import Any -from extended_data.state_utils import is_nothing -from extended_data.type_utils import make_hashable +from extended_data.primitives.state import is_nothing +from extended_data.primitives.types import make_hashable def is_partial_match( diff --git a/src/extended_data/number_transformations.py b/src/extended_data/primitives/numbers.py similarity index 100% rename from src/extended_data/number_transformations.py rename to src/extended_data/primitives/numbers.py diff --git a/src/extended_data/primitives/redaction.py b/src/extended_data/primitives/redaction.py new file mode 100644 index 0000000..5187776 --- /dev/null +++ b/src/extended_data/primitives/redaction.py @@ -0,0 +1,126 @@ +"""Tier 1 redaction helpers for diagnostics and JSON-like data.""" + +from __future__ import annotations + +import re + +from collections.abc import Iterable, Mapping +from typing import Any +from urllib.parse import quote + + +SENSITIVE_KEY_PATTERN = ( + r"api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|token|secret|password|passwd|pwd|" + r"authorization|client[_-]?secret|private[_-]?key" +) +SENSITIVE_KEY_RE = re.compile(rf"(?i)^(?:{SENSITIVE_KEY_PATTERN})$") +JSON_SECRET_RE = re.compile( + rf"(?i)([\"']?(?:{SENSITIVE_KEY_PATTERN})[\"']?\s*:\s*)" + rf"([\"'][^\"']*[\"']|Bearer\s+[^\s,;}}\]]+|[^,\s}}\]]+)" +) +KEY_VALUE_SECRET_RE = re.compile(rf"(?i)(\b(?:{SENSITIVE_KEY_PATTERN})\b\s*=\s*)([^\s,;&]+)") +CLI_SECRET_RE = re.compile(rf"(?i)(--(?:{SENSITIVE_KEY_PATTERN})(?:=|\s+))(\S+)") +BEARER_SECRET_RE = re.compile(r"(?i)(\bBearer\s+)[A-Za-z0-9._~+/=-]+") +REDACTED = "[REDACTED]" + + +def _redacted_value(value: str) -> str: + """Return a redacted placeholder while preserving matching quotes.""" + quote = value[:1] if value[:1] in {"'", '"'} else "" + return f"{quote}{REDACTED}{quote}" + + +def _redacted_field(match: re.Match[str]) -> str: + """Return a redacted key/value field while preserving JSON shape.""" + prefix = match.group(1) + value = match.group(2) + if prefix.lstrip().startswith(('"', "'")) and value[:1] not in {"'", '"'}: + return f'{prefix}"{REDACTED}"' + return f"{prefix}{_redacted_value(value)}" + + +def _iter_known_values(values: Iterable[Any]) -> Iterable[Any]: + """Yield scalar known-sensitive values from nested caller context.""" + for value in values: + if value is None: + continue + if isinstance(value, Mapping): + yield from _iter_known_values(value.values()) + elif isinstance(value, (str, bytes, bytearray)): + yield value + elif isinstance(value, Iterable): + yield from _iter_known_values(value) + else: + yield value + + +def _slash_encoding_variants(value: str) -> set[str]: + """Return common variants where any slash positions are URL encoded.""" + slash_count = value.count("/") + if slash_count == 0 or slash_count > 8: + return set() + + variants: set[str] = set() + for mask in range(1, 1 << slash_count): + slash_index = 0 + parts: list[str] = [] + for char in value: + if char == "/": + parts.append("%2F" if mask & (1 << slash_index) else "/") + slash_index += 1 + else: + parts.append(char) + variants.add("".join(parts)) + return variants + + +def _redact_known_values(text: str, values: Iterable[Any] | None) -> str: + """Redact explicitly provided values and URL-encoded variants.""" + if values is None: + return text + for value in _iter_known_values(values): + raw_value = str(value) + if not raw_value: + continue + slash_encoded = raw_value.replace("/", "%2F") + candidates = { + raw_value, + quote(raw_value, safe=""), + quote(raw_value, safe="/"), + slash_encoded, + } + candidates.update(_slash_encoding_variants(raw_value)) + for candidate in set(candidates) | {candidate.replace("%2F", "%2f") for candidate in candidates}: + text = text.replace(candidate, REDACTED) + return text + + +def redact_sensitive_text(message: Any, *, values: Iterable[Any] | None = None) -> str: + """Redact common secret fields in terminal-oriented text.""" + text = str(message) + text = JSON_SECRET_RE.sub(_redacted_field, text) + text = KEY_VALUE_SECRET_RE.sub(lambda match: f"{match.group(1)}{REDACTED}", text) + text = CLI_SECRET_RE.sub(lambda match: f"{match.group(1)}{REDACTED}", text) + text = BEARER_SECRET_RE.sub(rf"\1{REDACTED}", text) + return _redact_known_values(text, values) + + +def redact_sensitive_data(value: Any, *, values: Iterable[Any] | None = None) -> Any: + """Recursively redact common secret fields in JSON-like data.""" + if isinstance(value, Mapping): + redacted: dict[Any, Any] = {} + for key, item in value.items(): + if isinstance(key, str) and SENSITIVE_KEY_RE.fullmatch(key): + redacted[key] = REDACTED + else: + redacted[key] = redact_sensitive_data(item, values=values) + return redacted + if isinstance(value, list): + return [redact_sensitive_data(item, values=values) for item in value] + if isinstance(value, tuple): + return tuple(redact_sensitive_data(item, values=values) for item in value) + if isinstance(value, set): + return {redact_sensitive_data(item, values=values) for item in value} + if isinstance(value, str): + return redact_sensitive_text(value, values=values) + return value diff --git a/src/extended_data/list_data_type.py b/src/extended_data/primitives/sequences.py similarity index 68% rename from src/extended_data/list_data_type.py rename to src/extended_data/primitives/sequences.py index 7aee032..b87ee04 100644 --- a/src/extended_data/list_data_type.py +++ b/src/extended_data/primitives/sequences.py @@ -10,7 +10,11 @@ from __future__ import annotations -from typing import Any +from collections.abc import Iterable +from typing import Any, TypeVar + + +T = TypeVar("T") def flatten_list(matrix: list[Any]) -> list[Any]: @@ -44,39 +48,36 @@ def _flatten(lst: list[Any]) -> list[Any]: def filter_list( - items: list[str] | None, - allowlist: list[str] | None = None, - denylist: list[str] | None = None, -) -> list[str]: + items: Iterable[T] | None, + allowlist: Iterable[T] | None = None, + denylist: Iterable[T] | None = None, +) -> list[T]: """Filters a list based on allowlist and denylist. Args: - items (list[str] | None): The list to filter. - allowlist (list[str] | None): The list of allowed items. - denylist (list[str] | None): The list of denied items. + items: The values to filter. + allowlist: The allowed values. + denylist: The denied values. Returns: - list[str]: The filtered list. + The filtered list. """ if items is None: items = [] allowlist_provided = allowlist is not None - allowlist = allowlist or [] - denylist = denylist or [] - - allowed_set = set(allowlist) - denied_set = set(denylist) + allowlist = list(allowlist or []) + denylist = list(denylist or []) - enforce_allowlist = allowlist_provided and bool(allowed_set) + enforce_allowlist = allowlist_provided and bool(allowlist) filtered = [] for elem in items: - if enforce_allowlist and elem not in allowed_set: + if enforce_allowlist and elem not in allowlist: continue - if elem in denied_set: + if elem in denylist: continue filtered.append(elem) diff --git a/src/extended_data/serialization_utils.py b/src/extended_data/primitives/serialization.py similarity index 100% rename from src/extended_data/serialization_utils.py rename to src/extended_data/primitives/serialization.py diff --git a/src/extended_data/splitter_utils.py b/src/extended_data/primitives/splitting.py similarity index 97% rename from src/extended_data/splitter_utils.py rename to src/extended_data/primitives/splitting.py index 94ad35b..aecaab6 100644 --- a/src/extended_data/splitter_utils.py +++ b/src/extended_data/primitives/splitting.py @@ -15,7 +15,7 @@ from collections import defaultdict from typing import Any -from extended_data.type_utils import typeof +from extended_data.primitives.types import typeof def split_list_by_type( diff --git a/src/extended_data/state_utils.py b/src/extended_data/primitives/state.py similarity index 100% rename from src/extended_data/state_utils.py rename to src/extended_data/primitives/state.py diff --git a/src/extended_data/string_transformations.py b/src/extended_data/primitives/string_transforms.py similarity index 94% rename from src/extended_data/string_transformations.py rename to src/extended_data/primitives/string_transforms.py index 988a8ce..ff9d388 100644 --- a/src/extended_data/string_transformations.py +++ b/src/extended_data/primitives/string_transforms.py @@ -12,6 +12,7 @@ def _normalize_separators(text: str) -> str: + text = str(text) spaced = re.sub(r"(?<=[a-z])(?=[A-Z])", " ", text) return spaced.replace("-", " ").replace("_", " ") @@ -41,13 +42,13 @@ def to_kebab_case(text: str) -> str: def pluralize(text: str) -> str: """Convert string to plural form.""" - return inflection.pluralize(text) + return inflection.pluralize(str(text)) def singularize(text: str) -> str: """Convert string to singular form.""" - normalized = text - if text.lower() == "criteria": + normalized = str(text) + if normalized.lower() == "criteria": return "criterion" return inflection.singularize(normalized) diff --git a/src/extended_data/string_data_type.py b/src/extended_data/primitives/strings.py similarity index 59% rename from src/extended_data/string_data_type.py rename to src/extended_data/primitives/strings.py index a38122d..895c3df 100644 --- a/src/extended_data/string_data_type.py +++ b/src/extended_data/primitives/strings.py @@ -12,34 +12,31 @@ import inflection -def bytestostr(bstr: str | memoryview | bytes | bytearray) -> str: - """Converts bytes, memoryview, or bytearray to a UTF-8 decoded string. +def bytes_to_string(value: object) -> str: + """Convert bytes, memoryview, bytearray, or another object to a string. - This function takes an input which could be a string, memoryview, bytes, or bytearray, - and returns the corresponding UTF-8 decoded string. If the input is already a string, - it returns it unchanged. + Bytes-like values are decoded as UTF-8. Strings are returned unchanged and + all other objects use their standard string representation. Args: - bstr (str | memoryview | bytes | bytearray): The input to convert to a string. - Can be a `str`, `memoryview`, `bytes`, or `bytearray`. + value: The value to convert. Returns: - str: The UTF-8 decoded string representation of the input. + The string representation of the input. Raises: UnicodeDecodeError: If the bytes or bytearray cannot be decoded into a valid UTF-8 string. """ - if isinstance(bstr, str): - return bstr + if isinstance(value, str): + return value - if isinstance(bstr, memoryview): - bstr = bstr.tobytes() + if isinstance(value, memoryview): + value = value.tobytes() - if isinstance(bstr, (bytes, bytearray)): - return bstr.decode("utf-8") + if isinstance(value, (bytes, bytearray)): + return value.decode("utf-8") - # This return handles both bytes, bytearray, and memoryview after conversion to bytes - return str(bstr) + return str(value) def sanitize_key(key: str, delim: str = "_") -> str: @@ -52,6 +49,7 @@ def sanitize_key(key: str, delim: str = "_") -> str: Returns: str: The sanitized key. """ + key = str(key) return "".join(x if (x.isalnum() or x == delim) else delim for x in key) @@ -66,6 +64,8 @@ def truncate(msg: str, max_length: int, ender: str = "...") -> str: Returns: str: The truncated message. """ + msg = str(msg) + ender = str(ender) if max_length <= 0: return "" @@ -85,6 +85,7 @@ def lower_first_char(inp: str) -> str: Returns: str: The string with the first character in lowercase. """ + inp = str(inp) return inp[:1].lower() + inp[1:] if inp else "" @@ -97,6 +98,7 @@ def upper_first_char(inp: str) -> str: Returns: str: The string with the first character in uppercase. """ + inp = str(inp) return inp[:1].upper() + inp[1:] if inp else "" @@ -109,7 +111,7 @@ def is_url(url: str) -> bool: Returns: bool: True if the string is a valid URL, False otherwise. """ - parsed = urlparse(url.strip()) + parsed = urlparse(str(url).strip()) return all([parsed.scheme, parsed.netloc]) @@ -122,32 +124,4 @@ def titleize_name(name: str) -> str: Returns: str: The TitleCase name. """ - return inflection.titleize(inflection.underscore(name)) - - -def removeprefix(string: str, prefix: str) -> str: - """Removes the specified prefix from the string if present. - - Args: - string (str): The string from which to remove the prefix. - prefix (str): The prefix to remove. - - Returns: - str: The string with the prefix removed if it was present, otherwise the original string. - """ - return string.removeprefix(prefix) - - -def removesuffix(string: str, suffix: str) -> str: - """Removes the specified suffix from the string if present. - - Args: - string (str): The string from which to remove the suffix. - suffix (str): The suffix to remove. - - Returns: - str: The string with the suffix removed if it was present, otherwise the original string. - """ - if not suffix: - return string - return string.removesuffix(suffix) + return inflection.titleize(inflection.underscore(str(name))) diff --git a/src/extended_data/transformations/__init__.py b/src/extended_data/primitives/transformations/__init__.py similarity index 52% rename from src/extended_data/transformations/__init__.py rename to src/extended_data/primitives/transformations/__init__.py index 25822ec..9a3549c 100644 --- a/src/extended_data/transformations/__init__.py +++ b/src/extended_data/primitives/transformations/__init__.py @@ -2,8 +2,8 @@ from __future__ import annotations -from extended_data.transformations.numbers import notation, words -from extended_data.transformations.strings import inflection +from extended_data.primitives.transformations.numbers import notation, words +from extended_data.primitives.transformations.strings import inflection __all__ = [ diff --git a/src/extended_data/transformations/numbers/__init__.py b/src/extended_data/primitives/transformations/numbers/__init__.py similarity index 82% rename from src/extended_data/transformations/numbers/__init__.py rename to src/extended_data/primitives/transformations/numbers/__init__.py index bb1beb7..c0bfe50 100644 --- a/src/extended_data/transformations/numbers/__init__.py +++ b/src/extended_data/primitives/transformations/numbers/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from extended_data.transformations.numbers.notation import ( +from extended_data.primitives.transformations.numbers.notation import ( from_fraction, from_ordinal, from_roman, @@ -12,7 +12,7 @@ to_roman, to_words, ) -from extended_data.transformations.numbers.words import ( +from extended_data.primitives.transformations.numbers.words import ( fraction_to_words, number_to_words, ordinal_to_words, diff --git a/src/extended_data/transformations/numbers/notation.py b/src/extended_data/primitives/transformations/numbers/notation.py similarity index 98% rename from src/extended_data/transformations/numbers/notation.py rename to src/extended_data/primitives/transformations/numbers/notation.py index 3af2f53..8cfd068 100644 --- a/src/extended_data/transformations/numbers/notation.py +++ b/src/extended_data/primitives/transformations/numbers/notation.py @@ -7,7 +7,7 @@ from num2words import num2words -from extended_data.transformations.numbers import words as words_module +from extended_data.primitives.transformations.numbers import words as words_module _ROMAN_VALUES: Final[dict[str, int]] = { diff --git a/src/extended_data/transformations/numbers/words.py b/src/extended_data/primitives/transformations/numbers/words.py similarity index 100% rename from src/extended_data/transformations/numbers/words.py rename to src/extended_data/primitives/transformations/numbers/words.py diff --git a/src/extended_data/transformations/strings/__init__.py b/src/extended_data/primitives/transformations/strings/__init__.py similarity index 83% rename from src/extended_data/transformations/strings/__init__.py rename to src/extended_data/primitives/transformations/strings/__init__.py index ff810f9..1b2be27 100644 --- a/src/extended_data/transformations/strings/__init__.py +++ b/src/extended_data/primitives/transformations/strings/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from extended_data.transformations.strings.inflection import ( +from extended_data.primitives.transformations.strings.inflection import ( camelize, humanize, ordinalize, diff --git a/src/extended_data/transformations/strings/inflection.py b/src/extended_data/primitives/transformations/strings/inflection.py similarity index 100% rename from src/extended_data/transformations/strings/inflection.py rename to src/extended_data/primitives/transformations/strings/inflection.py diff --git a/src/extended_data/type_utils.py b/src/extended_data/primitives/types.py similarity index 87% rename from src/extended_data/type_utils.py rename to src/extended_data/primitives/types.py index e73088e..f614bd4 100644 --- a/src/extended_data/type_utils.py +++ b/src/extended_data/primitives/types.py @@ -24,6 +24,7 @@ - DATETIME_PATTERN: Regex for matching ISO 8601 datetime strings. - TIME_PATTERN: Regex for matching time strings. - PATH_PATTERN: Regex for matching Unix and Windows-style paths. + - INTEGER_PATTERN: Regex for matching integer strings. - NUMBER_PATTERN: Regex for matching numeric strings. - TRUTHY_PATTERN: Regex for matching truthy strings. - FALSY_PATTERN: Regex for matching falsy strings. @@ -37,16 +38,15 @@ import pathlib import re +from collections import UserList, UserString from collections.abc import Mapping +from collections.abc import Set as AbstractSet from pathlib import Path from typing import Any -from orjson import JSONDecodeError -from yaml.error import YAMLError - -from extended_data.json_utils import decode_json -from extended_data.string_data_type import removesuffix -from extended_data.yaml_utils import YamlPairs, YamlTagged, decode_yaml +from extended_data.primitives.formats.errors import DataDecodeError +from extended_data.primitives.formats.json import decode_json +from extended_data.primitives.formats.yaml import YamlPairs, YamlTagged, decode_yaml # Patterns for matching date, datetime, and time strings @@ -56,6 +56,7 @@ ) # Matches extended datetime formats like YYYY-MM-DDTHH:MM[:SS][.fff][Z|±hh:mm] TIME_PATTERN: re.Pattern[str] = re.compile(r"^\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?$") # Matches HH:MM[:SS] and microseconds PATH_PATTERN: re.Pattern[str] = re.compile(r'^(?:[a-zA-Z]:)?[\\/](?:[^<>:"|?*\n]+[\\/])*[^<>:"|?*\n]*$') +INTEGER_PATTERN: re.Pattern[str] = re.compile(r"^-?\d+$") NUMBER_PATTERN: re.Pattern[str] = re.compile(r"^-?\d+(\.\d+)?$") TRUTHY_PATTERN: re.Pattern[str] = re.compile(r"^(y|yes|t|true|on|1)$", re.IGNORECASE) FALSY_PATTERN: re.Pattern[str] = re.compile(r"^(n|no|f|false|off|0)$", re.IGNORECASE) @@ -113,7 +114,7 @@ def __init__(self, expected_type: builtins.type[Any], value: Any): super().__init__(f"Invalid {type_str} value: {self.value!r}") -def strtobool(val: str | bool | None, raise_on_error: bool = False) -> bool | None: +def string_to_bool(val: str | bool | None, raise_on_error: bool = False) -> bool | None: """Converts a string representation of truth to boolean. Args: @@ -129,6 +130,9 @@ def strtobool(val: str | bool | None, raise_on_error: bool = False) -> bool | No if isinstance(val, bool) or val is None: return val + if isinstance(val, UserString): + val = str(val) + if isinstance(val, str): if TRUTHY_PATTERN.match(val): return True @@ -141,7 +145,7 @@ def strtobool(val: str | bool | None, raise_on_error: bool = False) -> bool | No return None -def strtofloat(val: str, raise_on_error: bool = False) -> float | None: +def string_to_float(val: str, raise_on_error: bool = False) -> float | None: """Converts a string representation of a float to a float. Args: @@ -154,6 +158,7 @@ def strtofloat(val: str, raise_on_error: bool = False) -> float | None: Raises: ConversionError: If the value is invalid and raise_on_error is True. """ + val = str(val) if NUMBER_PATTERN.match(val): try: return float(val) @@ -167,7 +172,7 @@ def strtofloat(val: str, raise_on_error: bool = False) -> float | None: return None -def strtoint(val: str, raise_on_error: bool = False) -> int | None: +def string_to_int(val: str, raise_on_error: bool = False) -> int | None: """Converts a string representation of an integer to an int. Args: @@ -180,8 +185,9 @@ def strtoint(val: str, raise_on_error: bool = False) -> int | None: Raises: ConversionError: If the value is invalid and raise_on_error is True. """ + val = str(val) try: - float_value = strtofloat(val, raise_on_error=raise_on_error) + float_value = string_to_float(val, raise_on_error=raise_on_error) if float_value is not None: return int(float_value) except ConversionError as exc: @@ -194,7 +200,7 @@ def strtoint(val: str, raise_on_error: bool = False) -> int | None: return None -def strtopath(val: str | bytes | os.PathLike[str] | None, raise_on_error: bool = False) -> Path | None: +def string_to_path(val: str | bytes | os.PathLike[str] | None, raise_on_error: bool = False) -> Path | None: """Converts a string or byte representation of a path to a pathlib.Path object. Args: @@ -218,7 +224,8 @@ def strtopath(val: str | bytes | os.PathLike[str] | None, raise_on_error: bool = raise ConversionError(Path, val) from exc return None # Ensure val is converted to string before matching - if not PATH_PATTERN.match(str(val)): + val = str(val) + if not PATH_PATTERN.match(val): raise ConversionError(Path, val) return Path(val) except (ValueError, TypeError) as exc: @@ -227,7 +234,7 @@ def strtopath(val: str | bytes | os.PathLike[str] | None, raise_on_error: bool = return None -def strtodate(val: str, raise_on_error: bool = False) -> datetime.date | None: +def string_to_date(val: str, raise_on_error: bool = False) -> datetime.date | None: """Converts a string representation of a date to a datetime.date object. Args: @@ -240,6 +247,7 @@ def strtodate(val: str, raise_on_error: bool = False) -> datetime.date | None: Raises: ConversionError: If the value is invalid and raise_on_error is True. """ + val = str(val) if not DATE_PATTERN.match(val): if raise_on_error: raise ConversionError(datetime.date, val) @@ -252,7 +260,7 @@ def strtodate(val: str, raise_on_error: bool = False) -> datetime.date | None: return None -def strtodatetime(val: str, raise_on_error: bool = False) -> datetime.datetime | None: +def string_to_datetime(val: str, raise_on_error: bool = False) -> datetime.datetime | None: """Converts a string representation of a datetime to a datetime.datetime object. Args: @@ -266,6 +274,7 @@ def strtodatetime(val: str, raise_on_error: bool = False) -> datetime.datetime | Raises: ConversionError: If the value is invalid and raise_on_error is True. """ + val = str(val) if not DATETIME_PATTERN.match(val): if raise_on_error: raise ConversionError(datetime.datetime, val) @@ -282,7 +291,7 @@ def strtodatetime(val: str, raise_on_error: bool = False) -> datetime.datetime | return None -def strtotime(val: str, raise_on_error: bool = False) -> datetime.time | None: +def string_to_time(val: str, raise_on_error: bool = False) -> datetime.time | None: """Converts a string representation of a time to a datetime.time object. Args: @@ -295,6 +304,7 @@ def strtotime(val: str, raise_on_error: bool = False) -> datetime.time | None: Raises: ConversionError: If the value is invalid and raise_on_error is True. """ + val = str(val) if not TIME_PATTERN.match(val): if raise_on_error: raise ConversionError(datetime.time, val) @@ -327,11 +337,13 @@ def get_primitive_type_for_instance_type(value: Any) -> builtins.type[Any]: """Gets the primitive type for a given value.""" if isinstance(value, (bool, int, float, str, bytes, bytearray)): return type(value) - if isinstance(value, (list, tuple)): + if isinstance(value, UserString): + return str + if isinstance(value, (list, tuple, UserList)): return list - if isinstance(value, dict): + if isinstance(value, Mapping): return dict - if isinstance(value, (set, frozenset)): + if isinstance(value, (set, frozenset, AbstractSet)): return set return type(None) if value is None else object @@ -349,15 +361,15 @@ def convert_special_type(obj: Any) -> Any: return [convert_special_types(list(pair)) for pair in obj] if isinstance(obj, Mapping): return {k: convert_special_types(v) for k, v in obj.items()} - if isinstance(obj, (set, list, tuple, frozenset)): + if isinstance(obj, (set, list, tuple, frozenset, UserList, AbstractSet)) and not isinstance(obj, str | UserString): return [convert_special_types(v) for v in obj] if isinstance(obj, (datetime.date, datetime.datetime)): - return removesuffix(obj.isoformat(), "+00:00") + return obj.isoformat().removesuffix("+00:00") if isinstance(obj, pathlib.Path): return str(obj) - if isinstance(obj, (int, float, str, bool, type(None))): - return obj + if isinstance(obj, (int, float, str, bool, type(None), UserString)): + return str(obj) if isinstance(obj, UserString) else obj return str(obj) @@ -370,7 +382,7 @@ def convert_special_types(obj: Any) -> Any: return [convert_special_types(list(pair)) for pair in obj] if isinstance(obj, Mapping): return {k: convert_special_types(v) for k, v in obj.items()} - if isinstance(obj, (set, list, tuple, frozenset)): + if isinstance(obj, (set, list, tuple, frozenset, UserList, AbstractSet)) and not isinstance(obj, str | UserString): return [convert_special_types(v) for v in obj] return convert_special_type(obj) @@ -416,29 +428,29 @@ def reconstruct_special_type(converted_obj: str, fail_silently: bool = False) -> ConversionError: If reconstruction fails and fail_silently is False. """ try: - if converted_obj == "None": + if converted_obj in {"None", "null"}: return None if DATETIME_PATTERN.match(converted_obj): - return strtodatetime(converted_obj) + return string_to_datetime(converted_obj) if DATE_PATTERN.match(converted_obj): - return strtodate(converted_obj) + return string_to_date(converted_obj) if TIME_PATTERN.match(converted_obj): - return strtotime(converted_obj) + return string_to_time(converted_obj) if PATH_PATTERN.match(converted_obj): return pathlib.Path(converted_obj) if TRUTHY_PATTERN.match(converted_obj) or FALSY_PATTERN.match(converted_obj): - return strtobool(converted_obj) + return string_to_bool(converted_obj) if NUMBER_PATTERN.match(converted_obj): - if converted_obj.isdigit(): - return strtoint(converted_obj) - return strtofloat(converted_obj) + if INTEGER_PATTERN.match(converted_obj): + return string_to_int(converted_obj) + return string_to_float(converted_obj) if is_potential_yaml(converted_obj): return decode_yaml(converted_obj) if is_potential_json(converted_obj): return decode_json(converted_obj) - except (ValueError, TypeError, YAMLError, JSONDecodeError) as exc: + except (ValueError, TypeError, DataDecodeError) as exc: if not fail_silently: raise ConversionError(type(converted_obj), converted_obj) from exc return converted_obj @@ -454,11 +466,11 @@ def reconstruct_special_types(obj: Any, fail_silently: bool = False) -> Any: Returns: Any: The reconstructed object with special types restored where applicable. """ - if isinstance(obj, str): - return reconstruct_special_type(obj, fail_silently=fail_silently) + if isinstance(obj, str | UserString): + return reconstruct_special_type(str(obj), fail_silently=fail_silently) if isinstance(obj, Mapping): return {k: reconstruct_special_types(v, fail_silently=fail_silently) for k, v in obj.items()} - if isinstance(obj, list): + if isinstance(obj, list | UserList): return [reconstruct_special_types(v, fail_silently=fail_silently) for v in obj] if isinstance(obj, tuple): return tuple(reconstruct_special_types(v, fail_silently=fail_silently) for v in obj) @@ -466,6 +478,8 @@ def reconstruct_special_types(obj: Any, fail_silently: bool = False) -> Any: return {reconstruct_special_types(v, fail_silently=fail_silently) for v in obj} if isinstance(obj, frozenset): return frozenset(reconstruct_special_types(v, fail_silently=fail_silently) for v in obj) + if isinstance(obj, AbstractSet): + return {reconstruct_special_types(v, fail_silently=fail_silently) for v in obj} return obj diff --git a/src/extended_data/secrets/__init__.py b/src/extended_data/secrets/__init__.py index c7158cf..f4e4c5d 100644 --- a/src/extended_data/secrets/__init__.py +++ b/src/extended_data/secrets/__init__.py @@ -1,5 +1,6 @@ -"""Secret synchronization adapters for Extended Data.""" +"""SecretSync CLI bridge exports for Extended Data.""" +from extended_data._version import __version__ from extended_data.connectors.secrets import ( ConfigInfo, OutputFormat, @@ -21,6 +22,7 @@ "SyncOperation", "SyncOptions", "SyncResult", + "__version__", "get_crewai_tools", "get_langchain_tools", "get_strands_tools", diff --git a/src/extended_data/secrets/tools.py b/src/extended_data/secrets/tools.py deleted file mode 100644 index 712c45c..0000000 --- a/src/extended_data/secrets/tools.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Tool exports for secret synchronization workflows.""" - -from extended_data.connectors.secrets.tools import * # noqa: F403 diff --git a/src/extended_data/workflows/__init__.py b/src/extended_data/workflows/__init__.py index 63a5e94..18da550 100644 --- a/src/extended_data/workflows/__init__.py +++ b/src/extended_data/workflows/__init__.py @@ -1,3 +1,394 @@ -"""Workflow composition helpers for Extended Data.""" +"""Tier 3 workflow composition over Extended Data primitives and containers.""" -__all__: list[str] = [] +from __future__ import annotations + +from collections.abc import Callable, Iterable, Mapping +from copy import deepcopy +from dataclasses import dataclass, field +from pathlib import Path +from types import MappingProxyType +from typing import Any, TypeAlias + +from extended_data.containers import ExtendedDict, extend_data, to_builtin +from extended_data.io.exporters import make_raw_data_export_safe, wrap_raw_data_for_export +from extended_data.io.files import DataFile, FilePath, write_file + + +WorkflowAction: TypeAlias = Callable[[Any], Any] +StepLike: TypeAlias = "WorkflowStep | tuple[str, WorkflowAction] | WorkflowAction" +DATA_TRANSFORM_STEPS: Mapping[str, str] = MappingProxyType( + { + "compact": "compact", + "deduplicate": "deduplicate", + "flatten": "flatten", + "humanize": "humanize", + "reconstruct": "reconstruct_special_types", + "titleize": "titleize", + "to-camel-case": "to_camel_case", + "to-kebab-case": "to_kebab_case", + "to-pascal-case": "to_pascal_case", + "to-snake-case": "to_snake_case", + "unhump": "unhump", + "unique": "unique", + } +) + + +def list_data_transform_steps() -> tuple[str, ...]: + """Return the named transform steps supported by DataWorkflow.""" + return tuple(sorted(DATA_TRANSFORM_STEPS)) + + +def data_transform_action(step: str) -> WorkflowAction: + """Return a workflow action for one named Tier 2 transform step.""" + try: + method_name = DATA_TRANSFORM_STEPS[step] + except KeyError as exc: + expected = ", ".join(list_data_transform_steps()) + raise ValueError(f"unknown data transform {step!r}; expected one of: {expected}") from exc + + def transform(data: Any) -> Any: + method = _data_transform_method(data, step, method_name) + if not callable(method): + raise TypeError(f"transform {step!r} is not available for {type(data).__name__}") + return method() + + return transform + + +def _data_transform_method(data: Any, step: str, method_name: str) -> Any: + """Return the best method for a transform step on the current data shape.""" + if step == "reconstruct": + return getattr(data, "reconstruct_special_types", None) or getattr(data, "reconstruct_special_type", None) + return getattr(data, method_name, None) + + +@dataclass(frozen=True, slots=True) +class WorkflowStep: + """A named transformation in a data workflow.""" + + name: str + action: WorkflowAction + + def __call__(self, value: Any) -> Any: + """Apply the step to a workflow value.""" + return self.action(value) + + +@dataclass(frozen=True, slots=True, init=False) +class WorkflowResult: + """The completed value and audit trail for a data workflow.""" + + value: Any + steps: tuple[str, ...] + output_path: Path | None + _metadata: ExtendedDict = field(repr=False) + + def __init__( + self, + value: Any, + steps: Iterable[str] = (), + output_path: Path | None = None, + metadata: Mapping[str, Any] | None = None, + ) -> None: + """Store workflow metadata as promoted detached data.""" + object.__setattr__(self, "value", value) + object.__setattr__(self, "steps", tuple(steps)) + object.__setattr__(self, "output_path", output_path) + object.__setattr__(self, "_metadata", ExtendedDict(metadata or {})) + + @property + def metadata(self) -> ExtendedDict: + """Return a detached promoted copy of workflow metadata.""" + return ExtendedDict(to_builtin(self._metadata)) + + def as_builtin(self) -> Any: + """Return the workflow value lowered to built-in Python containers.""" + return to_builtin(self.value) + + def as_extended(self) -> Any: + """Return a detached workflow value promoted to Extended Data containers.""" + return extend_data(deepcopy(to_builtin(self.value))) + + def to_export_safe(self, *, export_to_yaml: bool = False) -> Any: + """Return the workflow value converted to export-safe primitive data.""" + return make_raw_data_export_safe(self.value, export_to_yaml=export_to_yaml) + + def wrap_for_export(self, allow_encoding: bool | str = True, **format_opts: Any) -> str: + """Return the workflow value wrapped as an encoded export string.""" + return wrap_raw_data_for_export(self.value, allow_encoding=allow_encoding, **format_opts) + + +class DataWorkflow: + """Compose file decoding, transformations, and exports as a Tier 3 primitive.""" + + def __init__( + self, + value: Any, + *, + steps: Iterable[str] = (), + as_extended: bool = True, + metadata: Mapping[str, Any] | None = None, + ) -> None: + """Create a workflow from an existing value.""" + self._value = extend_data(value) if as_extended else value + self._steps = tuple(steps) + self._as_extended = as_extended + self._metadata = ExtendedDict(metadata or {}) + + @property + def value(self) -> Any: + """Return the current workflow value.""" + return self._value + + @property + def steps(self) -> tuple[str, ...]: + """Return the names of executed workflow steps.""" + return self._steps + + @property + def metadata(self) -> ExtendedDict: + """Return a detached promoted copy of workflow metadata.""" + return ExtendedDict(to_builtin(self._metadata)) + + @classmethod + def from_value( + cls, + value: Any, + *, + as_extended: bool = True, + metadata: Mapping[str, Any] | None = None, + ) -> DataWorkflow: + """Start a workflow from an in-memory value.""" + return cls(value, steps=("value",), as_extended=as_extended, metadata=metadata) + + @classmethod + def from_data_file(cls, artifact: DataFile, *, as_extended: bool = True) -> DataWorkflow: + """Start a workflow from a decoded DataFile artifact.""" + value = artifact.as_extended() if as_extended else artifact.as_builtin() + return cls( + value, + steps=(f"data_file:{artifact.source}",), + as_extended=as_extended, + metadata=artifact.metadata, + ) + + @classmethod + def decode( + cls, + file_data: str | memoryview | bytes | bytearray, + *, + file_path: FilePath | None = None, + suffix: str | None = None, + as_extended: bool = True, + metadata: Mapping[str, Any] | None = None, + ) -> DataWorkflow: + """Start a workflow by decoding structured text or bytes.""" + artifact = DataFile.decode( + file_data, + file_path=file_path, + suffix=suffix, + as_extended=as_extended, + metadata=metadata, + ) + return cls( + artifact.data, + steps=(_decode_step_name(file_path=file_path, suffix=suffix),), + as_extended=as_extended, + metadata=artifact.metadata, + ) + + @classmethod + def from_file( + cls, + file_path: FilePath, + *, + suffix: str | None = None, + as_extended: bool = True, + charset: str = "utf-8", + errors: str = "strict", + tld: Path | None = None, + ) -> DataWorkflow: + """Read and decode a local file or URL into a workflow.""" + artifact = DataFile.read( + file_path, + suffix=suffix, + as_extended=as_extended, + charset=charset, + errors=errors, + tld=tld, + ) + return cls( + artifact.data, + steps=(f"read:{file_path}",), + as_extended=as_extended, + metadata=artifact.metadata, + ) + + def then( + self, + step: StepLike, + *, + name: str | None = None, + as_extended: bool | None = None, + ) -> DataWorkflow: + """Apply one transformation and return the next workflow state.""" + workflow_step = _coerce_step(step, name=name) + next_value = workflow_step(self._value) + should_extend = self._as_extended if as_extended is None else as_extended + if should_extend: + next_value = extend_data(next_value) + return DataWorkflow( + next_value, + steps=(*self._steps, workflow_step.name), + as_extended=should_extend, + metadata=self._metadata, + ) + + def run(self, *steps: StepLike, as_extended: bool | None = None) -> DataWorkflow: + """Apply multiple transformations in order.""" + workflow = self + for step in steps: + workflow = workflow.then(step, as_extended=as_extended) + return workflow + + def transform(self, *steps: str, as_extended: bool | None = None) -> DataWorkflow: + """Apply named Tier 2 transform steps in order.""" + if not steps: + raise ValueError("DataWorkflow.transform requires at least one step") + + workflow = self + for step in steps: + workflow = workflow.then((f"transform:{step}", data_transform_action(step)), as_extended=as_extended) + return workflow + + def merge( + self, + *mappings: Mapping[str, Any], + name: str = "merge", + as_extended: bool | None = None, + ) -> DataWorkflow: + """Deep-merge mappings into the current workflow value.""" + if not mappings: + raise ValueError("DataWorkflow.merge requires at least one mapping") + + return self.then((name, _deep_merge_action(*mappings)), as_extended=as_extended) + + def merge_file( + self, + file_path: FilePath, + *, + suffix: str | None = None, + charset: str = "utf-8", + errors: str = "strict", + tld: Path | None = None, + name: str | None = None, + as_extended: bool | None = None, + ) -> DataWorkflow: + """Read a structured file and deep-merge it into the workflow value.""" + artifact = DataFile.read(file_path, suffix=suffix, charset=charset, errors=errors, tld=tld) + return self.merge(artifact.as_extended(), name=name or f"merge:{file_path}", as_extended=as_extended) + + def as_builtin(self) -> DataWorkflow: + """Return the next workflow state with built-in Python containers.""" + return DataWorkflow( + to_builtin(self._value), + steps=(*self._steps, "to_builtin"), + as_extended=False, + metadata=self._metadata, + ) + + def as_extended(self) -> DataWorkflow: + """Return the next workflow state with Extended Data containers.""" + return DataWorkflow( + extend_data(self._value), + steps=(*self._steps, "as_extended"), + as_extended=True, + metadata=self._metadata, + ) + + def result(self) -> WorkflowResult: + """Return a completed workflow result without writing an output artifact.""" + return WorkflowResult(value=self._value, steps=self._steps, metadata=self.metadata) + + def write( + self, + file_path: FilePath, + *, + encoding: str | None = None, + charset: str = "utf-8", + allow_empty: bool = False, + tld: Path | None = None, + as_builtin: bool = True, + ) -> WorkflowResult: + """Write the current workflow value and return the completed result.""" + output_value = to_builtin(self._value) if as_builtin else self._value + output_path = write_file( + file_path, + output_value, + encoding=encoding, + charset=charset, + allow_empty=allow_empty, + tld=tld, + ) + if output_path is None: + raise ValueError("Workflow output was empty; pass allow_empty=True to write it") + + return WorkflowResult( + value=self._value, + steps=(*self._steps, f"write:{file_path}"), + output_path=output_path, + metadata=self.metadata, + ) + + +def _deep_merge_action(*mappings: Mapping[str, Any]) -> WorkflowAction: + """Return a typed workflow action that deep-merges mapping values.""" + merge_values = tuple(extend_data(deepcopy(to_builtin(mapping))) for mapping in mappings) + + def merge(data: Any) -> Any: + method = getattr(data, "deep_merge", None) + if not callable(method): + raise TypeError(f"merge is not available for {type(data).__name__}") + return method(*merge_values) + + return merge + + +def _coerce_step(step: StepLike, *, name: str | None = None) -> WorkflowStep: + """Normalize supported step declarations to WorkflowStep.""" + if isinstance(step, WorkflowStep): + if name is None: + return step + return WorkflowStep(name=name, action=step.action) + + if isinstance(step, tuple): + step_name, action = step + return WorkflowStep(name=name or step_name, action=action) + + inferred_name = name + if inferred_name is None: + raw_name = getattr(step, "__name__", None) + inferred_name = raw_name if isinstance(raw_name, str) else step.__class__.__name__ + return WorkflowStep(name=inferred_name, action=step) + + +def _decode_step_name(*, file_path: FilePath | None, suffix: str | None) -> str: + """Return a stable step name for decode-only workflows.""" + if file_path is not None: + return f"decode:{file_path}" + if suffix is not None: + return f"decode:{suffix}" + return "decode" + + +__all__ = [ + "DATA_TRANSFORM_STEPS", + "DataWorkflow", + "StepLike", + "WorkflowAction", + "WorkflowResult", + "WorkflowStep", + "data_transform_action", + "list_data_transform_steps", +] diff --git a/tests/connectors/meshy/conftest.py b/tests/connectors/meshy/conftest.py index 91c379f..fec352b 100644 --- a/tests/connectors/meshy/conftest.py +++ b/tests/connectors/meshy/conftest.py @@ -1,4 +1,4 @@ -"""Pytest fixtures for mesh-toolkit tests.""" +"""Pytest fixtures for Meshy connector tests.""" from __future__ import annotations diff --git a/tests/connectors/meshy/test_animations.py b/tests/connectors/meshy/test_animations.py new file mode 100644 index 0000000..a3bfcb8 --- /dev/null +++ b/tests/connectors/meshy/test_animations.py @@ -0,0 +1,60 @@ +"""Tests for Meshy animation catalog helpers.""" + +from __future__ import annotations + +import pytest + +from extended_data.connectors.meshy.animations import ( + ANIMATIONS, + AnimationCategory, + AnimationSubcategory, + get_animation, + get_animations_by_category, + get_animations_by_subcategory, +) +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString + + +def test_get_animation_returns_extended_payload() -> None: + """Single animation lookup should expose extended mapping payloads.""" + action_id, raw_animation = next(iter(ANIMATIONS.items())) + + result = get_animation(action_id) + + assert isinstance(result, ExtendedDict) + assert result["id"] == raw_animation.id + assert result["name"] == raw_animation.name + assert isinstance(result["name"], ExtendedString) + assert result["preview_url"] == raw_animation.preview_url + + +def test_get_animations_by_category_returns_extended_payloads() -> None: + """Category lookup should expose an extended list of extended mappings.""" + raw_animation = next(iter(ANIMATIONS.values())) + + result = get_animations_by_category(AnimationCategory(raw_animation.category)) + + assert isinstance(result, ExtendedList) + assert result + assert all(isinstance(animation, ExtendedDict) for animation in result) + assert all(animation["category"] == raw_animation.category for animation in result) + assert isinstance(result[0]["subcategory"], ExtendedString) + + +def test_get_animations_by_subcategory_returns_extended_payloads() -> None: + """Subcategory lookup should expose an extended list of extended mappings.""" + raw_animation = next(iter(ANIMATIONS.values())) + + result = get_animations_by_subcategory(AnimationSubcategory(raw_animation.subcategory)) + + assert isinstance(result, ExtendedList) + assert result + assert all(isinstance(animation, ExtendedDict) for animation in result) + assert all(animation["subcategory"] == raw_animation.subcategory for animation in result) + assert isinstance(result[0]["name"], ExtendedString) + + +def test_get_animation_rejects_unknown_id() -> None: + """Missing animations should remain explicit errors.""" + with pytest.raises(ValueError, match="Animation ID -1 not found"): + get_animation(-1) diff --git a/tests/connectors/meshy/test_jobs.py b/tests/connectors/meshy/test_jobs.py index dc76db7..2ee2752 100644 --- a/tests/connectors/meshy/test_jobs.py +++ b/tests/connectors/meshy/test_jobs.py @@ -6,6 +6,7 @@ from unittest.mock import patch +from extended_data.connectors.meshy import jobs as jobs_module from extended_data.connectors.meshy.jobs import ( AssetGenerator, AssetManifest, @@ -22,6 +23,11 @@ Text3DResult, TextureUrls, ) +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString, extend_data + + +def _extended_result(result: Text3DResult) -> ExtendedDict: + return extend_data(result.model_dump(mode="json")) class TestAssetManifest: @@ -50,6 +56,8 @@ def test_manifest_to_dict(self): model_path="models/test.glb", ) data = manifest.to_dict() + assert isinstance(data, ExtendedDict) + assert isinstance(data["asset_id"], ExtendedString) assert data["asset_id"] == "test-001" assert data["model_path"] == "models/test.glb" @@ -125,9 +133,11 @@ def test_generate_model_no_wait(self, temp_dir): manifest = generator.generate_model(spec, wait=False) - assert manifest.asset_id == "project1-001" - assert manifest.task_id == "task-12345" - assert manifest.model_path is None # Not downloaded yet + assert isinstance(manifest, ExtendedDict) + assert isinstance(manifest["asset_id"], ExtendedString) + assert manifest["asset_id"] == "project1-001" + assert manifest["task_id"] == "task-12345" + assert manifest["model_path"] is None # Not downloaded yet mock_text3d.create.assert_called_once() def test_generate_model_with_wait(self, temp_dir): @@ -137,7 +147,7 @@ def test_generate_model_with_wait(self, temp_dir): patch("extended_data.connectors.meshy.jobs.base") as mock_base, ): mock_text3d.create.return_value = "task-12345" - mock_text3d.poll.return_value = Text3DResult( + mock_text3d.poll.return_value = _extended_result(Text3DResult( id="task-12345", status=TaskStatus.SUCCEEDED, progress=100, @@ -145,7 +155,7 @@ def test_generate_model_with_wait(self, temp_dir): model_urls=ModelUrls(glb="https://example.com/model.glb"), texture_urls=[TextureUrls(base_color="https://example.com/base.png")], thumbnail_url="https://example.com/thumb.png", - ) + )) mock_base.download.return_value = 1000 generator = AssetGenerator(output_root=str(temp_dir)) @@ -159,9 +169,10 @@ def test_generate_model_with_wait(self, temp_dir): manifest = generator.generate_model(spec, wait=True, poll_interval=0.01) - assert manifest.asset_id == "project1-001" - assert manifest.model_path is not None - assert "project1-001.glb" in manifest.model_path + assert isinstance(manifest, ExtendedDict) + assert manifest["asset_id"] == "project1-001" + assert manifest["model_path"] is not None + assert "project1-001.glb" in manifest["model_path"] mock_base.download.assert_called() def test_generate_model_saves_manifest_json(self, temp_dir): @@ -171,13 +182,13 @@ def test_generate_model_saves_manifest_json(self, temp_dir): patch("extended_data.connectors.meshy.jobs.base") as mock_base, ): mock_text3d.create.return_value = "task-12345" - mock_text3d.poll.return_value = Text3DResult( + mock_text3d.poll.return_value = _extended_result(Text3DResult( id="task-12345", status=TaskStatus.SUCCEEDED, progress=100, created_at=1700000000, model_urls=ModelUrls(glb="https://example.com/model.glb"), - ) + )) mock_base.download.return_value = 1000 generator = AssetGenerator(output_root=str(temp_dir)) @@ -189,7 +200,11 @@ def test_generate_model_saves_manifest_json(self, temp_dir): asset_id="barrel-001", ) - generator.generate_model(spec, wait=True, poll_interval=0.01) + with patch( + "extended_data.connectors.meshy.jobs.wrap_raw_data_for_export", + wraps=jobs_module.wrap_raw_data_for_export, + ) as mock_wrap_for_export: + generator.generate_model(spec, wait=True, poll_interval=0.01) manifest_path = temp_dir / "models" / "props" / "barrel-001_manifest.json" assert manifest_path.exists() @@ -197,6 +212,8 @@ def test_generate_model_saves_manifest_json(self, temp_dir): with open(manifest_path) as f: saved_manifest = json.load(f) assert saved_manifest["asset_id"] == "barrel-001" + mock_wrap_for_export.assert_called_once() + assert mock_wrap_for_export.call_args.kwargs == {"allow_encoding": "json", "indent_2": True} def test_batch_generate(self, temp_dir): """Test batch generation of multiple assets.""" @@ -205,13 +222,13 @@ def test_batch_generate(self, temp_dir): patch("extended_data.connectors.meshy.jobs.base") as mock_base, ): mock_text3d.create.return_value = "task-12345" - mock_text3d.poll.return_value = Text3DResult( + mock_text3d.poll.return_value = _extended_result(Text3DResult( id="task-12345", status=TaskStatus.SUCCEEDED, progress=100, created_at=1700000000, model_urls=ModelUrls(glb="https://example.com/model.glb"), - ) + )) mock_base.download.return_value = 1000 generator = AssetGenerator(output_root=str(temp_dir)) @@ -233,9 +250,11 @@ def test_batch_generate(self, temp_dir): manifests = generator.batch_generate(specs) + assert isinstance(manifests, ExtendedList) + assert isinstance(manifests[0], ExtendedDict) assert len(manifests) == 2 - assert manifests[0].asset_id == "item-001" - assert manifests[1].asset_id == "item-002" + assert manifests[0]["asset_id"] == "item-001" + assert manifests[1]["asset_id"] == "item-002" def test_batch_generate_continues_on_failure(self, temp_dir): """Test that batch generation continues if one fails.""" @@ -253,13 +272,13 @@ def create_side_effect(*args, **kwargs): return "task-success" mock_text3d.create.side_effect = create_side_effect - mock_text3d.poll.return_value = Text3DResult( + mock_text3d.poll.return_value = _extended_result(Text3DResult( id="task-success", status=TaskStatus.SUCCEEDED, progress=100, created_at=1700000000, model_urls=ModelUrls(glb="https://example.com/model.glb"), - ) + )) mock_base.download.return_value = 1000 generator = AssetGenerator(output_root=str(temp_dir)) @@ -282,8 +301,9 @@ def create_side_effect(*args, **kwargs): manifests = generator.batch_generate(specs) # Only the successful one should be in results + assert isinstance(manifests, ExtendedList) assert len(manifests) == 1 - assert manifests[0].asset_id == "success-001" + assert manifests[0]["asset_id"] == "success-001" class TestExampleSpecs: diff --git a/tests/connectors/meshy/test_meshy_base.py b/tests/connectors/meshy/test_meshy_base.py new file mode 100644 index 0000000..4ee2a5a --- /dev/null +++ b/tests/connectors/meshy/test_meshy_base.py @@ -0,0 +1,240 @@ +"""Tests for Meshy connector HTTP base helpers.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import httpx +import pytest + +from extended_data.connectors.meshy import base +from extended_data.connectors.meshy.models import Text3DResult +from extended_data.containers import ExtendedDict, ExtendedString + + +@pytest.fixture(autouse=True) +def reset_meshy_base(monkeypatch: pytest.MonkeyPatch) -> None: + """Reset Meshy base globals so helper tests stay isolated.""" + monkeypatch.setattr(base, "_client", None) + monkeypatch.setattr(base, "_inputs", None) + monkeypatch.setattr(base, "_last_request_time", 0) + monkeypatch.setattr(base, "_min_request_interval", 0.5) + + +def _raw_request(*args, **kwargs): + return base.request.__wrapped__(*args, **kwargs) + + +def test_configure_sets_and_merges_api_inputs() -> None: + """Meshy configuration should feed the shared InputProvider boundary.""" + base.configure(api_key="first-key", EXTRA_INPUT="value") + + assert base.get_api_key() == "first-key" + + base.configure(api_key="second-key") + + assert base.get_api_key() == "second-key" + + +def test_get_client_reuses_client_and_close_resets(monkeypatch: pytest.MonkeyPatch) -> None: + """Meshy HTTP clients should be lazy, reused, and closed explicitly.""" + client = MagicMock(spec=httpx.Client) + client_factory = MagicMock(return_value=client) + monkeypatch.setattr(base.httpx, "Client", client_factory) + + assert base.get_client() is client + assert base.get_client() is client + client_factory.assert_called_once_with(timeout=300.0) + + base.close() + + client.close.assert_called_once_with() + assert base._client is None + + +def test_rate_limit_sleeps_only_when_interval_has_not_elapsed(monkeypatch: pytest.MonkeyPatch) -> None: + """Rate limiting should sleep only for the remaining interval.""" + sleep = MagicMock() + monkeypatch.setattr(base.time, "sleep", sleep) + monkeypatch.setattr(base.time, "time", MagicMock(side_effect=[100.2, 100.7, 102.0, 102.0])) + monkeypatch.setattr(base, "_last_request_time", 100.0) + + base._rate_limit() + assert pytest.approx(sleep.call_args.args[0]) == 0.3 + assert base._last_request_time == 100.7 + + sleep.reset_mock() + base._rate_limit() + sleep.assert_not_called() + assert base._last_request_time == 102.0 + + +def test_headers_uses_bearer_api_key(monkeypatch: pytest.MonkeyPatch) -> None: + """Meshy request headers should be built from the configured API key.""" + monkeypatch.setattr(base, "get_api_key", lambda: "test-key") + + assert base._headers() == { + "Authorization": "Bearer test-key", + "Content-Type": "application/json", + } + + +def test_meshy_request_redacts_sensitive_error_body(monkeypatch: pytest.MonkeyPatch) -> None: + """Meshy API errors should not expose raw response secrets.""" + mock_client = MagicMock() + mock_client.request.return_value = httpx.Response( + 400, + content=b'{"api_key":"key_123","message":"Authorization: Bearer raw_token"}', + ) + + monkeypatch.setattr(base, "_rate_limit", lambda: None) + monkeypatch.setattr(base, "_headers", lambda: {"Authorization": "Bearer test"}) + monkeypatch.setattr(base, "get_client", lambda: mock_client) + + with pytest.raises(base.MeshyAPIError) as exc_info: + base.request("GET", "text-to-3d") + + message = str(exc_info.value) + assert exc_info.value.status_code == 400 + assert "key_123" not in message + assert "raw_token" not in message + assert "[REDACTED]" in message + + +def test_meshy_request_builds_url_and_returns_success(monkeypatch: pytest.MonkeyPatch) -> None: + """Meshy requests should build versioned OpenAPI URLs with shared headers.""" + response = httpx.Response(200, content=b'{"result":"task-123"}') + mock_client = MagicMock() + mock_client.request.return_value = response + monkeypatch.setattr(base, "_rate_limit", lambda: None) + monkeypatch.setattr(base, "_headers", lambda: {"Authorization": "Bearer test"}) + monkeypatch.setattr(base, "get_client", lambda: mock_client) + + result = _raw_request("POST", "text-to-3d", version="v2", json={"prompt": "ship"}) + + assert result is response + mock_client.request.assert_called_once_with( + "POST", + "https://api.meshy.ai/openapi/v2/text-to-3d", + headers={"Authorization": "Bearer test"}, + json={"prompt": "ship"}, + ) + + +def test_meshy_request_raises_rate_limit_for_429_and_5xx(monkeypatch: pytest.MonkeyPatch) -> None: + """Meshy retryable responses should raise RateLimitError with bounded sleeps.""" + mock_client = MagicMock() + mock_client.request.side_effect = [ + httpx.Response(429, headers={"retry-after": "0.25"}), + httpx.Response(429, headers={"retry-after": "not-a-number"}), + httpx.Response(503, content=b"unavailable"), + ] + sleep = MagicMock() + monkeypatch.setattr(base, "_rate_limit", lambda: None) + monkeypatch.setattr(base, "_headers", lambda: {"Authorization": "Bearer test"}) + monkeypatch.setattr(base, "get_client", lambda: mock_client) + monkeypatch.setattr(base.time, "sleep", sleep) + + with pytest.raises(base.RateLimitError, match=r"0\.25s"): + _raw_request("GET", "text-to-3d") + sleep.assert_called_once_with(0.25) + + sleep.reset_mock() + with pytest.raises(base.RateLimitError, match="not-a-number"): + _raw_request("GET", "text-to-3d") + sleep.assert_called_once_with(5) + + with pytest.raises(base.RateLimitError, match="Server error 503"): + _raw_request("GET", "text-to-3d") + + +def test_task_failure_message_redacts_sensitive_values() -> None: + """Meshy task failure messages should share the connector redaction boundary.""" + message = base.task_failure_message({"message": "failed password=hunter2 Authorization: Bearer raw_token"}) + + assert message.startswith("Task failed:") + assert "hunter2" not in message + assert "raw_token" not in message + assert "[REDACTED]" in message + + +def test_task_failure_message_falls_back_to_error_and_unknown() -> None: + """Task failure messages should preserve useful public errors.""" + assert base.task_failure_message({"error": "bad mesh"}) == "Task failed: bad mesh" + assert base.task_failure_message(None) == "Task failed: Unknown error" + + +def test_unexpected_response_message_redacts_sensitive_payloads() -> None: + """Unexpected response diagnostics should not echo secret-bearing payloads.""" + message = base.unexpected_response_message({"api_key": "key_123", "message": "Authorization: Bearer raw_token"}) + + assert "key_123" not in message + assert "raw_token" not in message + assert "[REDACTED]" in message + + +def test_decode_response_json_handles_empty_and_extended_payloads() -> None: + """Response JSON decoding should promote payloads across the data boundary.""" + assert base._decode_response_json(httpx.Response(204, content=b"")) is None + + result = base._decode_response_json(httpx.Response(200, content=b'{"result":"task-123"}')) + + assert isinstance(result, ExtendedDict) + assert result["result"] == "task-123" + + +def test_task_id_from_response_extracts_non_empty_result() -> None: + """Task creation responses should expose non-empty task IDs as ExtendedString.""" + task_id = base.task_id_from_response(httpx.Response(200, content=b'{"result":"task-123"}')) + + assert isinstance(task_id, ExtendedString) + assert task_id == "task-123" + + +def test_task_id_from_response_rejects_missing_or_blank_results() -> None: + """Task creation responses should fail loudly for unusable response bodies.""" + with pytest.raises(RuntimeError, match="missing 'result' key"): + base.task_id_from_response(httpx.Response(200, content=b'{"api_key":"key_123","result":" "}')) + + +def test_task_payload_from_response_validates_and_promotes_model_payload() -> None: + """Task status responses should validate through Pydantic and return extended data.""" + response = httpx.Response( + 200, + content=b'{"id":"task-123","status":"SUCCEEDED","progress":100,"created_at":1700000000}', + ) + + result = base.task_payload_from_response(response, Text3DResult, "text-to-3d") + + assert isinstance(result, ExtendedDict) + assert result["id"] == "task-123" + assert result["status"] == "SUCCEEDED" + + +def test_task_payload_from_response_redacts_invalid_payloads() -> None: + """Task status validation errors should redact unexpected vendor payloads.""" + response = httpx.Response(200, content=b'{"api_key":"key_123","status":"FAILED"}') + + with pytest.raises(RuntimeError) as exc_info: + base.task_payload_from_response(response, Text3DResult, "text-to-3d") + + message = str(exc_info.value) + assert "key_123" not in message + assert "[REDACTED]" in message + + +def test_download_creates_parent_directories_and_returns_size(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: + """Meshy downloads should write bytes and return the downloaded size.""" + response = MagicMock() + response.content = b"glb-bytes" + response.raise_for_status = MagicMock() + get = MagicMock(return_value=response) + monkeypatch.setattr(base.httpx, "get", get) + output_path = tmp_path / "nested" / "model.glb" + + size = base.download("https://assets.meshy.ai/model.glb", str(output_path)) + + assert size == len(b"glb-bytes") + assert output_path.read_bytes() == b"glb-bytes" + response.raise_for_status.assert_called_once_with() + get.assert_called_once_with("https://assets.meshy.ai/model.glb") diff --git a/tests/connectors/meshy/test_meshy_logging.py b/tests/connectors/meshy/test_meshy_logging.py new file mode 100644 index 0000000..133d233 --- /dev/null +++ b/tests/connectors/meshy/test_meshy_logging.py @@ -0,0 +1,45 @@ +"""Tests for Meshy logging helpers.""" + +from __future__ import annotations + +import logging + +from extended_data.connectors.meshy import MESHY_LOGGER_NAME, MESHY_STORAGE_MARKER, create_meshy_logger +from extended_data.logging import Logging + + +def test_create_meshy_logger_returns_extended_data_logger() -> None: + """Meshy logging should use the package lifecycle logging surface.""" + logger = create_meshy_logger(level="WARNING") + + assert isinstance(logger, Logging) + assert logger.logger.name == MESHY_LOGGER_NAME + assert logger.logger.level == logging.WARNING + assert logger.enable_console is False + assert logger.enable_file is False + assert logger.default_storage_marker == MESHY_STORAGE_MARKER + + +def test_create_meshy_logger_uses_tier2_storage_and_redaction() -> None: + """Meshy logging should keep stored connector messages promoted and redacted.""" + logger = create_meshy_logger( + level="INFO", + default_storage_marker="asset-generation", + allowed_levels=["info"], + ) + + result = logger.logged_statement( + "Meshy request failed with Authorization: Bearer raw_token", + json_data={"api_key": "key_123", "task_id": "task_456"}, + log_level="info", + ) + + assert result is not None + assert "raw_token" not in result + assert "key_123" not in result + stored = logger.get_stored_messages("asset-generation") + assert len(stored) == 1 + stored_message = next(iter(stored)) + assert "raw_token" not in stored_message + assert "key_123" not in stored_message + assert "task_456" in stored_message diff --git a/tests/connectors/meshy/test_meshy_mcp.py b/tests/connectors/meshy/test_meshy_mcp.py new file mode 100644 index 0000000..6b93a25 --- /dev/null +++ b/tests/connectors/meshy/test_meshy_mcp.py @@ -0,0 +1,195 @@ +"""Tests for Meshy MCP serialization helpers.""" + +from __future__ import annotations + +import json + +from unittest.mock import patch + +import pytest + +from extended_data.connectors.meshy import mcp as meshy_mcp_module +from extended_data.connectors.meshy.mcp import ( + _jsonable_tool_result, + _tool_error_payload, + _tool_result_text, + create_server, +) +from extended_data.containers import ExtendedDict, ExtendedSet + + +def test_meshy_mcp_result_lowers_and_redacts_extended_payloads() -> None: + """Meshy MCP result serialization should handle Tier 2 payloads directly.""" + payload = ExtendedDict( + { + "service": {"name": "meshy"}, + "password": "hunter2", + "tags": ExtendedSet({"asset", "model"}), + } + ) + + result = _jsonable_tool_result(payload) + + assert result["service"] == {"name": "meshy"} + assert result["password"] == "[REDACTED]" + assert sorted(result["tags"]) == ["asset", "model"] + + +def test_meshy_mcp_result_text_uses_shared_export_boundary() -> None: + """Meshy MCP text payloads should serialize through the Tier 3 export boundary.""" + payload = ExtendedDict({"service": {"name": "meshy"}}) + + with patch( + "extended_data.connectors.meshy.mcp.wrap_raw_data_for_export", + wraps=meshy_mcp_module.wrap_raw_data_for_export, + ) as mock_wrap_for_export: + text = _tool_result_text(payload) + + assert '"service": {' in text + mock_wrap_for_export.assert_called_once_with( + {"service": {"name": "meshy"}}, + allow_encoding="json", + indent_2=True, + ) + + +def test_meshy_mcp_error_payload_redacts_sensitive_values() -> None: + """Meshy MCP errors should not return raw secret-bearing exception text.""" + payload = _tool_error_payload(RuntimeError("failed api_key=key_123 Bearer raw_token")) + + assert "key_123" not in payload["error"] + assert "raw_token" not in payload["error"] + assert "[REDACTED]" in payload["error"] + + +def test_meshy_mcp_error_payload_redacts_unknown_tool_names() -> None: + """Meshy MCP unknown-tool diagnostics should redact user-controlled names.""" + payload = _tool_error_payload("Unknown tool: password=hunter2 Authorization: Bearer raw_token") + + assert "hunter2" not in payload["error"] + assert "raw_token" not in payload["error"] + assert "[REDACTED]" in payload["error"] + + +def test_meshy_mcp_error_payload_redacts_argument_values() -> None: + """Meshy MCP errors should redact operation-specific argument values.""" + payload = _tool_error_payload( + RuntimeError("failed for user@example.com"), + values=["user@example.com"], + ) + + assert "user@example.com" not in payload["error"] + assert "[REDACTED]" in payload["error"] + + +@pytest.mark.asyncio +async def test_create_server_registered_list_tools_handler_exposes_meshy_tools() -> None: + """The registered Meshy MCP list-tools handler should expose expected schemas.""" + mcp_types = pytest.importorskip("mcp.types") + + server = create_server() + result = await server.request_handlers[mcp_types.ListToolsRequest](mcp_types.ListToolsRequest()) + tools = {tool.name: tool for tool in result.root.tools} + + assert "text3d_generate" in tools + assert tools["text3d_generate"].inputSchema["required"] == ["prompt"] + assert tools["text3d_generate"].inputSchema["properties"]["enable_pbr"]["type"] == "boolean" + assert "check_task_status" in tools + assert tools["check_task_status"].inputSchema["properties"]["task_type"]["default"] == "text-to-3d" + + +@pytest.mark.asyncio +async def test_create_server_registered_call_handler_redacts_payloads() -> None: + """The registered Meshy MCP call handler should serialize and redact tool results.""" + mcp_types = pytest.importorskip("mcp.types") + + def fake_tool(enabled: bool = False) -> ExtendedDict: + return ExtendedDict({"enabled": enabled, "password": "hunter2"}) + + tool = mcp_types.Tool( + name="fake_meshy_tool", + description="Fake Meshy tool.", + inputSchema={ + "type": "object", + "properties": {"enabled": {"type": "boolean", "default": False}}, + "required": [], + }, + ) + + with patch("extended_data.connectors.meshy.mcp._create_mcp_tools", return_value=[(tool, fake_tool)]): + server = create_server() + await server.request_handlers[mcp_types.ListToolsRequest](mcp_types.ListToolsRequest()) + result = await server.request_handlers[mcp_types.CallToolRequest]( + mcp_types.CallToolRequest( + params=mcp_types.CallToolRequestParams( + name="fake_meshy_tool", + arguments={"enabled": True}, + ) + ) + ) + + assert json.loads(result.root.content[0].text) == {"enabled": True, "password": "[REDACTED]"} + + +@pytest.mark.asyncio +async def test_create_server_registered_call_handler_redacts_error_argument_values() -> None: + """The registered Meshy MCP call handler should redact operation-specific error values.""" + mcp_types = pytest.importorskip("mcp.types") + + def fake_tool(email: str) -> None: + raise RuntimeError(f"failed for {email} with api_key=key_123") + + tool = mcp_types.Tool( + name="fake_meshy_tool", + description="Fake Meshy tool.", + inputSchema={ + "type": "object", + "properties": {"email": {"type": "string"}}, + "required": ["email"], + }, + ) + + with patch("extended_data.connectors.meshy.mcp._create_mcp_tools", return_value=[(tool, fake_tool)]): + server = create_server() + await server.request_handlers[mcp_types.ListToolsRequest](mcp_types.ListToolsRequest()) + result = await server.request_handlers[mcp_types.CallToolRequest]( + mcp_types.CallToolRequest( + params=mcp_types.CallToolRequestParams( + name="fake_meshy_tool", + arguments={"email": "user@example.com"}, + ) + ) + ) + + payload = json.loads(result.root.content[0].text) + assert "user@example.com" not in payload["error"] + assert "key_123" not in payload["error"] + assert "[REDACTED]" in payload["error"] + + +@pytest.mark.asyncio +async def test_create_server_registered_call_handler_accepts_missing_arguments() -> None: + """The registered Meshy MCP call handler should treat omitted arguments as empty.""" + mcp_types = pytest.importorskip("mcp.types") + + def fake_tool() -> ExtendedDict: + return ExtendedDict({"status": "ok"}) + + tool = mcp_types.Tool( + name="fake_meshy_tool", + description="Fake Meshy tool.", + inputSchema={"type": "object", "properties": {}, "required": []}, + ) + + with patch("extended_data.connectors.meshy.mcp._create_mcp_tools", return_value=[(tool, fake_tool)]): + server = create_server() + await server.request_handlers[mcp_types.ListToolsRequest](mcp_types.ListToolsRequest()) + result = await server.request_handlers[mcp_types.CallToolRequest]( + mcp_types.CallToolRequest( + params=mcp_types.CallToolRequestParams( + name="fake_meshy_tool", + ) + ) + ) + + assert json.loads(result.root.content[0].text) == {"status": "ok"} diff --git a/tests/connectors/meshy/test_repository.py b/tests/connectors/meshy/test_repository.py index e8ad2f9..9221f42 100644 --- a/tests/connectors/meshy/test_repository.py +++ b/tests/connectors/meshy/test_repository.py @@ -5,16 +5,20 @@ import json from datetime import datetime, timezone +from unittest.mock import patch import pytest +from extended_data.connectors.meshy.persistence import repository as repository_module from extended_data.connectors.meshy.persistence.repository import TaskRepository from extended_data.connectors.meshy.persistence.schemas import ( ArtifactRecord, AssetManifest, + ProjectManifest, TaskStatus, TaskSubmission, ) +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString class TestTaskRepositoryInit: @@ -40,9 +44,11 @@ def test_load_creates_new_manifest(self, task_repository): """Test loading non-existent project creates new manifest.""" manifest = task_repository.load_project_manifest("project1") - assert manifest.project == "project1" - assert manifest.asset_specs == {} - assert manifest.version == "1.0" + assert isinstance(manifest, ExtendedDict) + assert manifest["project"] == "project1" + assert manifest["asset_specs"] == {} + assert manifest["version"] == "1.0" + assert isinstance(manifest["last_updated"], ExtendedString) def test_load_existing_manifest(self, task_repository, temp_dir): """Test loading existing manifest.""" @@ -62,11 +68,37 @@ def test_load_existing_manifest(self, task_repository, temp_dir): json.dump(manifest_data, f) manifest = task_repository.load_project_manifest("project2") - assert manifest.project == "project2" + assert isinstance(manifest, ExtendedDict) + assert manifest["project"] == "project2" + + def test_load_existing_manifest_decodes_through_data_file_boundary(self, task_repository, temp_dir): + """Existing manifests should be read through DataFile, not local json.load.""" + project_dir = temp_dir / "project2" + project_dir.mkdir() + manifest_path = project_dir / "manifest.json" + manifest_path.write_text( + json.dumps( + { + "project": "project2", + "asset_specs": {}, + "version": "1.0", + "last_updated": datetime.now(timezone.utc).isoformat(), + } + ) + ) + + with patch( + "extended_data.connectors.meshy.persistence.repository.DataFile.read", + wraps=repository_module.DataFile.read, + ) as mock_read: + manifest = task_repository.load_project_manifest("project2") + + assert manifest["project"] == "project2" + mock_read.assert_called_once_with(manifest_path, as_extended=False) def test_save_and_load_manifest(self, task_repository): """Test saving and reloading manifest.""" - manifest = task_repository.load_project_manifest("project1") + manifest = ProjectManifest(project="project1") # Add an asset record asset = AssetManifest( @@ -81,8 +113,22 @@ def test_save_and_load_manifest(self, task_repository): # Reload and verify reloaded = task_repository.load_project_manifest("project1") - assert "hash-123" in reloaded.asset_specs - assert reloaded.asset_specs["hash-123"].project == "project1" + assert isinstance(reloaded, ExtendedDict) + assert "hash-123" in reloaded["asset_specs"] + assert reloaded["asset_specs"]["hash-123"]["project"] == "project1" + + def test_save_manifest_encodes_through_export_boundary(self, task_repository): + """Saved manifests should use the shared export boundary.""" + manifest = ProjectManifest(project="project1") + + with patch( + "extended_data.connectors.meshy.persistence.repository.wrap_raw_data_for_export", + wraps=repository_module.wrap_raw_data_for_export, + ) as mock_wrap_for_export: + task_repository.save_project_manifest(manifest) + + assert mock_wrap_for_export.called + assert mock_wrap_for_export.call_args.kwargs == {"allow_encoding": "json", "indent_2": True} class TestAssetRecordOperations: @@ -107,8 +153,10 @@ def test_upsert_and_get_asset_record(self, task_repository): retrieved = task_repository.get_asset_record("project1", "hash-abc") assert retrieved is not None - assert retrieved.asset_spec_hash == "hash-abc" - assert retrieved.prompts["text3d"] == "An project1 character" + assert isinstance(retrieved, ExtendedDict) + assert retrieved["asset_spec_hash"] == "hash-abc" + assert retrieved["prompts"]["text3d"] == "An project1 character" + assert isinstance(retrieved["prompts"]["text3d"], ExtendedString) def test_upsert_updates_existing(self, task_repository): """Test that upsert updates existing record.""" @@ -125,7 +173,8 @@ def test_upsert_updates_existing(self, task_repository): task_repository.upsert_asset_record("project1", asset) retrieved = task_repository.get_asset_record("project1", "hash-abc") - assert retrieved.prompts["text3d"] == "Updated prompt" + assert retrieved is not None + assert retrieved["prompts"]["text3d"] == "Updated prompt" class TestTaskSubmission: @@ -147,9 +196,9 @@ def test_record_task_submission(self, task_repository): # Verify it was saved asset = task_repository.get_asset_record("project1", "hash-abc") assert asset is not None - assert len(asset.task_graph) == 1 - assert asset.task_graph[0].task_id == "task-12345" - assert asset.task_graph[0].service == "text3d" + assert len(asset["task_graph"]) == 1 + assert asset["task_graph"][0]["task_id"] == "task-12345" + assert asset["task_graph"][0]["service"] == "text3d" def test_record_duplicate_submission_idempotent(self, task_repository): """Test that duplicate submissions are idempotent.""" @@ -166,7 +215,8 @@ def test_record_duplicate_submission_idempotent(self, task_repository): task_repository.record_task_submission(submission) # Duplicate asset = task_repository.get_asset_record("project1", "hash-abc") - assert len(asset.task_graph) == 1 # Still just one task + assert asset is not None + assert len(asset["task_graph"]) == 1 # Still just one task def test_record_submission_validates_fields(self, task_repository): """Test that submission validation works.""" @@ -210,9 +260,10 @@ def test_record_task_update(self, repo_with_task): ) asset = repo_with_task.get_asset_record("project1", "hash-abc") - task = asset.task_graph[0] - assert task.status == "SUCCEEDED" - assert task.result_paths["glb"] == "https://example.com/model.glb" + assert asset is not None + task = asset["task_graph"][0] + assert task["status"] == "SUCCEEDED" + assert task["result_paths"]["glb"] == "https://example.com/model.glb" def test_record_task_update_with_error(self, repo_with_task): """Test updating task with error.""" @@ -225,9 +276,27 @@ def test_record_task_update_with_error(self, repo_with_task): ) asset = repo_with_task.get_asset_record("project1", "hash-abc") - task = asset.task_graph[0] - assert task.status == "FAILED" - assert task.error == "Generation failed" + assert asset is not None + task = asset["task_graph"][0] + assert task["status"] == "FAILED" + assert task["error"] == "Generation failed" + + def test_record_task_update_redacts_error(self, repo_with_task): + """Persisted task errors should be redacted at the repository boundary.""" + repo_with_task.record_task_update( + project="project1", + spec_hash="hash-abc", + task_id="task-12345", + status="FAILED", + error="Generation failed password=hunter2 Authorization: Bearer raw_token", + ) + + asset = repo_with_task.get_asset_record("project1", "hash-abc") + assert asset is not None + task = asset["task_graph"][0] + assert "hunter2" not in task["error"] + assert "raw_token" not in task["error"] + assert "[REDACTED]" in task["error"] def test_record_task_update_adds_history(self, repo_with_task): """Test that updates add history entries.""" @@ -240,17 +309,18 @@ def test_record_task_update_adds_history(self, repo_with_task): ) asset = repo_with_task.get_asset_record("project1", "hash-abc") - assert len(asset.history) >= 1 + assert asset is not None + assert len(asset["history"]) >= 1 # Find the update entry update_entry = None - for entry in asset.history: - if entry.new_status == "SUCCEEDED": + for entry in asset["history"]: + if entry["new_status"] == "SUCCEEDED": update_entry = entry break assert update_entry is not None - assert update_entry.source == "webhook" + assert update_entry["source"] == "webhook" def test_record_task_update_not_found_raises(self, task_repository): """Test that updating non-existent asset raises.""" @@ -281,8 +351,9 @@ def test_record_task_update_with_artifacts(self, repo_with_task): ) asset = repo_with_task.get_asset_record("project1", "hash-abc") - assert len(asset.artifacts) == 1 - assert asset.artifacts[0].relative_path == "hash-abc_text3d.glb" + assert asset is not None + assert len(asset["artifacts"]) == 1 + assert asset["artifacts"][0]["relative_path"] == "hash-abc_text3d.glb" class TestTaskLookup: @@ -308,17 +379,18 @@ def test_find_task_by_id_with_project(self, repo_with_tasks): result = repo_with_tasks.find_task_by_id("task-project1-123", project="project1") assert result is not None - project, spec_hash, _asset = result - assert project == "project1" - assert spec_hash == "hash-project1" + assert isinstance(result, ExtendedDict) + assert result["project"] == "project1" + assert result["spec_hash"] == "hash-project1" + assert result["asset"]["asset_spec_hash"] == "hash-project1" def test_find_task_by_id_without_project(self, repo_with_tasks): """Test finding task by scanning all project.""" result = repo_with_tasks.find_task_by_id("task-project2-123") assert result is not None - project, _spec_hash, _asset = result - assert project == "project2" + assert isinstance(result, ExtendedDict) + assert result["project"] == "project2" def test_find_task_not_found(self, repo_with_tasks): """Test finding non-existent task.""" @@ -343,8 +415,9 @@ def test_list_pending_assets(self, task_repository): task_repository.record_task_submission(submission) pending = task_repository.list_pending_assets("project1") + assert isinstance(pending, ExtendedList) assert len(pending) == 1 - assert pending[0].asset_spec_hash == "hash-pending" + assert pending[0]["asset_spec_hash"] == "hash-pending" def test_list_pending_excludes_completed(self, task_repository): """Test that completed assets are not listed.""" diff --git a/tests/connectors/meshy/test_task_ids.py b/tests/connectors/meshy/test_task_ids.py new file mode 100644 index 0000000..9c39b2d --- /dev/null +++ b/tests/connectors/meshy/test_task_ids.py @@ -0,0 +1,503 @@ +"""Tests for Meshy task-id API helpers.""" + +from __future__ import annotations + +import json + +from unittest.mock import MagicMock, call, patch + +import httpx +import pytest + +from extended_data.connectors.meshy import animate, image3d, retexture, rigging, text3d +from extended_data.connectors.meshy.models import ( + AnimationRequest, + ArtStyle, + Image3DRequest, + RetextureRequest, + RiggingRequest, + TaskStatus, + Text3DRequest, +) +from extended_data.containers import ExtendedDict, ExtendedString + + +def _task_response(task_id: str) -> MagicMock: + return _json_response({"result": task_id}) + + +def _json_response(payload: object) -> MagicMock: + response = MagicMock(spec=httpx.Response) + response.content = json.dumps(payload).encode() + response.json.side_effect = AssertionError("Meshy responses must be decoded from response content") + return response + + +def test_text3d_task_ids_are_extended_strings() -> None: + with patch("extended_data.connectors.meshy.text3d.base.request", return_value=_task_response("text-task")): + created = text3d.create(Text3DRequest(prompt="a sword")) + refined = text3d.refine("text-task") + + assert isinstance(created, ExtendedString) + assert isinstance(refined, ExtendedString) + assert created == "text-task" + assert refined == "text-task" + + +def test_image3d_task_ids_are_extended_strings() -> None: + with patch("extended_data.connectors.meshy.image3d.base.request", return_value=_task_response("image-task")): + created = image3d.create(Image3DRequest(image_url="https://example.com/source.png")) + refined = image3d.refine("image-task") + + assert isinstance(created, ExtendedString) + assert isinstance(refined, ExtendedString) + assert created == "image-task" + assert refined == "image-task" + + +def test_animation_task_id_is_extended_string() -> None: + request = AnimationRequest(rig_task_id="rig-task", action_id=42) + + with patch("extended_data.connectors.meshy.animate.base.request", return_value=_task_response("animation-task")): + created = animate.create(request) + + assert isinstance(created, ExtendedString) + assert created == "animation-task" + + +def test_rigging_task_id_is_extended_string() -> None: + request = RiggingRequest(input_task_id="model-task") + + with patch("extended_data.connectors.meshy.rigging.base.request", return_value=_task_response("rig-task")): + created = rigging.create(request) + + assert isinstance(created, ExtendedString) + assert created == "rig-task" + + +def test_retexture_task_id_is_extended_string() -> None: + request = RetextureRequest(input_task_id="model-task", text_style_prompt="gold") + + with patch("extended_data.connectors.meshy.retexture.base.request", return_value=_task_response("retexture-task")): + created = retexture.create(request) + + assert isinstance(created, ExtendedString) + assert created == "retexture-task" + + +def test_text3d_generate_builds_request_and_waits() -> None: + """Text generation should build the preview request and poll the created task.""" + completed = ExtendedDict({"id": "text-task", "status": TaskStatus.SUCCEEDED}) + + with ( + patch("extended_data.connectors.meshy.text3d.create", return_value=ExtendedString("text-task")) as create, + patch("extended_data.connectors.meshy.text3d.poll", return_value=completed) as poll, + ): + result = text3d.generate( + "a low-poly castle", + art_style="sculpture", + negative_prompt="blurry", + target_polycount=1234, + enable_pbr=False, + ) + + assert result is completed + create.assert_called_once() + request = create.call_args.args[0] + assert isinstance(request, Text3DRequest) + assert request.mode == "preview" + assert request.prompt == "a low-poly castle" + assert request.art_style == ArtStyle.SCULPTURE + assert request.negative_prompt == "blurry" + assert request.target_polycount == 1234 + assert request.enable_pbr is False + poll.assert_called_once_with("text-task") + + +def test_text3d_generate_without_wait_returns_task_id() -> None: + """Text generation should expose submitted task IDs without polling when wait is disabled.""" + with ( + patch("extended_data.connectors.meshy.text3d.create", return_value=ExtendedString("text-task")) as create, + patch("extended_data.connectors.meshy.text3d.poll") as poll, + ): + result = text3d.generate("a low-poly crate", wait=False) + + assert isinstance(result, ExtendedString) + assert result == "text-task" + create.assert_called_once() + poll.assert_not_called() + + +def test_image3d_generate_builds_request_and_waits() -> None: + """Image generation should build the preview request and poll the created task.""" + completed = ExtendedDict({"id": "image-task", "status": TaskStatus.SUCCEEDED}) + + with ( + patch("extended_data.connectors.meshy.image3d.create", return_value=ExtendedString("image-task")) as create, + patch("extended_data.connectors.meshy.image3d.poll", return_value=completed) as poll, + ): + result = image3d.generate( + "https://example.com/source.png", + topology="quad", + target_polycount=4321, + enable_pbr=False, + ) + + assert result is completed + create.assert_called_once() + request = create.call_args.args[0] + assert isinstance(request, Image3DRequest) + assert request.mode == "preview" + assert request.image_url == "https://example.com/source.png" + assert request.topology == "quad" + assert request.target_polycount == 4321 + assert request.enable_pbr is False + poll.assert_called_once_with("image-task") + + +def test_animation_apply_builds_request_and_waits() -> None: + """Animation application should build the animation request and poll the created task.""" + completed = ExtendedDict({"id": "animation-task", "status": TaskStatus.SUCCEEDED}) + + with ( + patch("extended_data.connectors.meshy.animate.create", return_value=ExtendedString("animation-task")) as create, + patch("extended_data.connectors.meshy.animate.poll", return_value=completed) as poll, + ): + result = animate.apply("rig-task", 42, loop=False, frame_rate=24) + + assert result is completed + create.assert_called_once() + request = create.call_args.args[0] + assert isinstance(request, AnimationRequest) + assert request.rig_task_id == "rig-task" + assert request.action_id == 42 + assert request.loop is False + assert request.frame_rate == 24 + poll.assert_called_once_with("animation-task") + + +def test_rigging_helpers_build_task_and_url_requests() -> None: + """Rigging helpers should build task-id and model-url request payloads.""" + with ( + patch("extended_data.connectors.meshy.rigging.create", return_value=ExtendedString("rig-task")) as create, + patch("extended_data.connectors.meshy.rigging.poll") as poll, + ): + task_result = rigging.rig("model-task", height_meters=1.9, wait=False) + url_result = rigging.rig_from_url( + "https://example.com/model.glb", + height_meters=1.8, + texture_url="https://example.com/texture.png", + wait=False, + ) + + assert task_result == "rig-task" + assert url_result == "rig-task" + task_request, url_request = [call.args[0] for call in create.call_args_list] + assert isinstance(task_request, RiggingRequest) + assert task_request.input_task_id == "model-task" + assert task_request.height_meters == 1.9 + assert isinstance(url_request, RiggingRequest) + assert url_request.model_url == "https://example.com/model.glb" + assert url_request.texture_image_url == "https://example.com/texture.png" + assert url_request.height_meters == 1.8 + poll.assert_not_called() + + +def test_rigging_helpers_poll_when_wait_enabled() -> None: + """Rigging helpers should poll created task IDs when wait is enabled.""" + task_completed = ExtendedDict({"id": "rig-task", "status": TaskStatus.SUCCEEDED}) + url_completed = ExtendedDict({"id": "url-rig-task", "status": TaskStatus.SUCCEEDED}) + + with ( + patch( + "extended_data.connectors.meshy.rigging.create", + side_effect=[ExtendedString("rig-task"), ExtendedString("url-rig-task")], + ), + patch("extended_data.connectors.meshy.rigging.poll", side_effect=[task_completed, url_completed]) as poll, + ): + task_result = rigging.rig("model-task") + url_result = rigging.rig_from_url("https://example.com/model.glb") + + assert task_result is task_completed + assert url_result is url_completed + assert poll.call_args_list == [call("rig-task"), call("url-rig-task")] + + +def test_retexture_helpers_build_text_and_image_style_requests() -> None: + """Retexture helpers should build text-prompt and image-reference request payloads.""" + with ( + patch("extended_data.connectors.meshy.retexture.create", return_value=ExtendedString("retexture-task")) as create, + patch("extended_data.connectors.meshy.retexture.poll") as poll, + ): + text_result = retexture.apply( + "model-task", + "gold leaf", + enable_original_uv=False, + enable_pbr=False, + wait=False, + ) + image_result = retexture.apply_from_image( + "model-task", + "https://example.com/style.png", + enable_original_uv=True, + enable_pbr=True, + wait=False, + ) + + assert text_result == "retexture-task" + assert image_result == "retexture-task" + text_request, image_request = [call.args[0] for call in create.call_args_list] + assert isinstance(text_request, RetextureRequest) + assert text_request.input_task_id == "model-task" + assert text_request.text_style_prompt == "gold leaf" + assert text_request.enable_original_uv is False + assert text_request.enable_pbr is False + assert isinstance(image_request, RetextureRequest) + assert image_request.image_style_url == "https://example.com/style.png" + assert image_request.enable_original_uv is True + assert image_request.enable_pbr is True + poll.assert_not_called() + + +def test_retexture_helpers_poll_when_wait_enabled() -> None: + """Retexture helpers should poll created task IDs when wait is enabled.""" + text_completed = ExtendedDict({"id": "retexture-task", "status": TaskStatus.SUCCEEDED}) + image_completed = ExtendedDict({"id": "image-retexture-task", "status": TaskStatus.SUCCEEDED}) + + with ( + patch( + "extended_data.connectors.meshy.retexture.create", + side_effect=[ExtendedString("retexture-task"), ExtendedString("image-retexture-task")], + ), + patch("extended_data.connectors.meshy.retexture.poll", side_effect=[text_completed, image_completed]) as poll, + ): + text_result = retexture.apply("model-task", "gold leaf") + image_result = retexture.apply_from_image("model-task", "https://example.com/style.png") + + assert text_result is text_completed + assert image_result is image_completed + assert poll.call_args_list == [call("retexture-task"), call("image-retexture-task")] + + +@pytest.mark.parametrize( + ("request_path", "call"), + [ + ( + "extended_data.connectors.meshy.text3d.base.request", + lambda: text3d.create(Text3DRequest(prompt="a sword")), + ), + ( + "extended_data.connectors.meshy.text3d.base.request", + lambda: text3d.refine("text-task"), + ), + ( + "extended_data.connectors.meshy.image3d.base.request", + lambda: image3d.create(Image3DRequest(image_url="https://example.com/source.png")), + ), + ( + "extended_data.connectors.meshy.image3d.base.request", + lambda: image3d.refine("image-task"), + ), + ( + "extended_data.connectors.meshy.animate.base.request", + lambda: animate.create(AnimationRequest(rig_task_id="rig-task", action_id=42)), + ), + ( + "extended_data.connectors.meshy.rigging.base.request", + lambda: rigging.create(RiggingRequest(input_task_id="model-task")), + ), + ( + "extended_data.connectors.meshy.retexture.base.request", + lambda: retexture.create(RetextureRequest(input_task_id="model-task", text_style_prompt="gold")), + ), + ], +) +def test_meshy_task_id_responses_fail_loudly_without_string_result(request_path: str, call) -> None: + """Task creation/refinement must not convert malformed vendor payloads into None.""" + response = _json_response({"password": "hunter2", "authorization": "Bearer raw_token", "result": None}) + + with patch(request_path, return_value=response): + with pytest.raises(RuntimeError, match="missing 'result' key") as exc_info: + call() + + message = str(exc_info.value) + assert "hunter2" not in message + assert "raw_token" not in message + assert "[REDACTED]" in message + + +def test_text3d_get_returns_extended_payload() -> None: + payload = { + "id": "text-task", + "status": "SUCCEEDED", + "progress": 100, + "created_at": 1700000000, + "model_urls": {"glb": "https://example.com/model.glb"}, + } + with patch("extended_data.connectors.meshy.text3d.base.request", return_value=_json_response(payload)): + result = text3d.get("text-task") + + assert isinstance(result, ExtendedDict) + assert isinstance(result["id"], ExtendedString) + assert isinstance(result["model_urls"], ExtendedDict) + assert result["model_urls"]["glb"] == "https://example.com/model.glb" + + +def test_image3d_get_returns_extended_payload() -> None: + payload = { + "id": "image-task", + "status": "SUCCEEDED", + "progress": 100, + "created_at": 1700000000, + "model_urls": {"glb": "https://example.com/image.glb"}, + } + with patch("extended_data.connectors.meshy.image3d.base.request", return_value=_json_response(payload)): + result = image3d.get("image-task") + + assert isinstance(result, ExtendedDict) + assert isinstance(result["model_urls"], ExtendedDict) + assert result["model_urls"]["glb"] == "https://example.com/image.glb" + + +def test_animation_get_returns_extended_payload() -> None: + payload = { + "id": "animation-task", + "status": "SUCCEEDED", + "progress": 100, + "created_at": 1700000000, + "animation_glb_url": "https://example.com/animation.glb", + } + with patch("extended_data.connectors.meshy.animate.base.request", return_value=_json_response(payload)): + result = animate.get("animation-task") + + assert isinstance(result, ExtendedDict) + assert isinstance(result["animation_glb_url"], ExtendedString) + assert result["animation_glb_url"] == "https://example.com/animation.glb" + + +def test_rigging_get_returns_extended_payload() -> None: + payload = { + "id": "rig-task", + "status": "SUCCEEDED", + "progress": 100, + "created_at": 1700000000, + "result": {"rigged_character_glb_url": "https://example.com/rig.glb"}, + } + with patch("extended_data.connectors.meshy.rigging.base.request", return_value=_json_response(payload)): + result = rigging.get("rig-task") + + assert isinstance(result, ExtendedDict) + assert isinstance(result["result"], ExtendedDict) + assert result["result"]["rigged_character_glb_url"] == "https://example.com/rig.glb" + + +def test_retexture_get_returns_extended_payload() -> None: + payload = { + "id": "retexture-task", + "status": "SUCCEEDED", + "progress": 100, + "created_at": 1700000000, + "model_urls": {"glb": "https://example.com/retexture.glb"}, + } + with patch("extended_data.connectors.meshy.retexture.base.request", return_value=_json_response(payload)): + result = retexture.get("retexture-task") + + assert isinstance(result, ExtendedDict) + assert isinstance(result["model_urls"], ExtendedDict) + assert result["model_urls"]["glb"] == "https://example.com/retexture.glb" + + +@pytest.mark.parametrize( + ("request_path", "call"), + [ + ("extended_data.connectors.meshy.text3d.base.request", lambda: text3d.get("text-task")), + ("extended_data.connectors.meshy.image3d.base.request", lambda: image3d.get("image-task")), + ("extended_data.connectors.meshy.animate.base.request", lambda: animate.get("animation-task")), + ("extended_data.connectors.meshy.rigging.base.request", lambda: rigging.get("rig-task")), + ("extended_data.connectors.meshy.retexture.base.request", lambda: retexture.get("retexture-task")), + ], +) +def test_meshy_get_responses_redact_validation_failures(request_path: str, call) -> None: + """Malformed status payloads should not expose raw vendor data through Pydantic errors.""" + response = _json_response({ + "status": "SUCCEEDED", + "created_at": 1700000000, + "password": "hunter2", + "authorization": "Bearer raw_token", + }) + + with patch(request_path, return_value=response): + with pytest.raises(RuntimeError, match="Unexpected API response") as exc_info: + call() + + message = str(exc_info.value) + assert "hunter2" not in message + assert "raw_token" not in message + assert "ValidationError" not in message + assert "[REDACTED]" in message + + +@pytest.mark.parametrize("module", [text3d, image3d, retexture, rigging, animate]) +def test_meshy_poll_returns_succeeded_tasks(monkeypatch: pytest.MonkeyPatch, module: object) -> None: + """All Meshy polling helpers should return succeeded task payloads directly.""" + completed = ExtendedDict({"id": "task-123", "status": TaskStatus.SUCCEEDED}) + monkeypatch.setattr(module, "get", lambda task_id: completed) + + result = module.poll("task-123", interval=0, timeout=1) + + assert result is completed + + +@pytest.mark.parametrize("module", [text3d, image3d, retexture, rigging, animate]) +def test_meshy_poll_redacts_failed_task_errors(monkeypatch: pytest.MonkeyPatch, module: object) -> None: + """All Meshy polling helpers should redact vendor task failure messages.""" + monkeypatch.setattr( + module, + "get", + lambda task_id: { + "id": task_id, + "status": "FAILED", + "task_error": {"message": "denied password=hunter2 Authorization: Bearer raw_token"}, + "error": "denied api_key=key_123", + }, + ) + + with pytest.raises(RuntimeError) as exc_info: + module.poll("task-secret", interval=0, timeout=1) + + message = str(exc_info.value) + assert "hunter2" not in message + assert "raw_token" not in message + assert "key_123" not in message + assert "[REDACTED]" in message + + +@pytest.mark.parametrize("module", [text3d, image3d, retexture, rigging, animate]) +def test_meshy_poll_raises_for_expired_tasks(monkeypatch: pytest.MonkeyPatch, module: object) -> None: + """All Meshy polling helpers should fail loudly when tasks expire.""" + monkeypatch.setattr(module, "get", lambda task_id: {"id": task_id, "status": TaskStatus.EXPIRED}) + + with pytest.raises(RuntimeError, match="Task expired"): + module.poll("task-expired", interval=0, timeout=1) + + +@pytest.mark.parametrize("module", [text3d, image3d, retexture, rigging, animate]) +def test_meshy_poll_times_out_pending_tasks(monkeypatch: pytest.MonkeyPatch, module: object) -> None: + """All Meshy polling helpers should time out pending tasks.""" + times = iter([0.0, 2.0]) + monkeypatch.setattr(module, "get", lambda task_id: {"id": task_id, "status": TaskStatus.PENDING}) + monkeypatch.setattr(module.time, "time", lambda: next(times)) + monkeypatch.setattr(module.time, "sleep", MagicMock()) + + with pytest.raises(TimeoutError, match="Task timed out after 1s"): + module.poll("task-pending", interval=0, timeout=1) + + +@pytest.mark.parametrize("payload", [{"result": ""}, {"result": 123}, ["not", "a", "mapping"]]) +def test_meshy_task_id_response_requires_non_empty_string_result(payload: object) -> None: + """Task ids are string API handles, not arbitrary JSON payload values.""" + response = _json_response(payload) + + with patch("extended_data.connectors.meshy.image3d.base.request", return_value=response): + with pytest.raises(RuntimeError, match="missing 'result' key"): + image3d.create(Image3DRequest(image_url="https://example.com/source.png")) diff --git a/tests/connectors/meshy/test_tools.py b/tests/connectors/meshy/test_tools.py index 170f999..9943853 100644 --- a/tests/connectors/meshy/test_tools.py +++ b/tests/connectors/meshy/test_tools.py @@ -7,10 +7,14 @@ from __future__ import annotations +import importlib.util + from unittest.mock import MagicMock, patch import pytest +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString, extend_data + # Expected tools list - canonical reference for all Meshy tools EXPECTED_MESHY_TOOLS = { @@ -80,11 +84,32 @@ def test_successful_generation(self): art_style="realistic", ) + assert isinstance(result, ExtendedDict) + assert isinstance(result["task_id"], ExtendedString) assert result["task_id"] == "task_123" assert result["status"] == "SUCCEEDED" assert result["model_url"] == "https://example.com/model.glb" assert result["thumbnail_url"] == "https://example.com/thumb.png" + def test_successful_generation_accepts_extended_payload(self): + """Tool wrapper should consume the real extended result payload shape.""" + from extended_data.connectors.meshy.tools import text3d_generate + + mock_result = extend_data({ + "id": "task_123", + "status": "SUCCEEDED", + "model_urls": {"glb": "https://example.com/model.glb"}, + "thumbnail_url": "https://example.com/thumb.png", + }) + + with patch("extended_data.connectors.meshy.text3d.generate", return_value=mock_result): + result = text3d_generate(prompt="a medieval sword") + + assert isinstance(result, ExtendedDict) + assert result["task_id"] == "task_123" + assert result["status"] == "SUCCEEDED" + assert result["model_url"] == "https://example.com/model.glb" + def test_generation_with_defaults(self): """Test generation with default parameters. @@ -114,6 +139,7 @@ def test_generation_with_defaults(self): wait=True, ) + assert isinstance(result, ExtendedDict) assert result["task_id"] == "task_456" @@ -137,6 +163,8 @@ def test_successful_image_to_3d(self): topology="quad", ) + assert isinstance(result, ExtendedDict) + assert isinstance(result["model_url"], ExtendedString) assert result["task_id"] == "img_task_456" assert result["status"] == "SUCCEEDED" assert result["model_url"] == "https://example.com/img_model.glb" @@ -156,6 +184,8 @@ def test_successful_rigging_with_wait(self): with patch("extended_data.connectors.meshy.rigging.rig", return_value=mock_result): result = rig_model(model_id="model_123", wait=True) + assert isinstance(result, ExtendedDict) + assert isinstance(result["message"], ExtendedString) assert result["task_id"] == "rig_789" assert result["status"] == "SUCCEEDED" assert "Rigging completed" in result["message"] @@ -168,6 +198,7 @@ def test_rigging_without_wait(self): with patch("extended_data.connectors.meshy.rigging.rig", return_value="pending_rig_task"): result = rig_model(model_id="model_123", wait=False) + assert isinstance(result, ExtendedDict) assert result["task_id"] == "pending_rig_task" assert result["status"] == "pending" @@ -191,6 +222,8 @@ def test_successful_animation(self): wait=True, ) + assert isinstance(result, ExtendedDict) + assert isinstance(result["glb_url"], ExtendedString) assert result["task_id"] == "anim_task_123" assert result["status"] == "SUCCEEDED" assert result["glb_url"] == "https://example.com/animated.glb" @@ -206,6 +239,7 @@ def test_animation_without_wait(self): wait=False, ) + assert isinstance(result, ExtendedDict) assert result["task_id"] == "anim_pending" assert result["status"] == "pending" @@ -228,6 +262,7 @@ def test_successful_retexture(self): texture_prompt="golden metallic finish", ) + assert isinstance(result, ExtendedDict) assert result["task_id"] == "retex_123" assert result["status"] == "SUCCEEDED" @@ -257,6 +292,9 @@ def test_list_all_animations(self): with patch("extended_data.connectors.meshy.animations.ANIMATIONS", mock_animations): result = list_animations() + assert isinstance(result, ExtendedDict) + assert isinstance(result["animations"], ExtendedList) + assert isinstance(result["animations"][0], ExtendedDict) assert result["count"] == 2 assert result["total"] == 2 assert len(result["animations"]) == 2 @@ -282,6 +320,8 @@ def test_list_animations_with_category_filter(self): with patch("extended_data.connectors.meshy.animations.ANIMATIONS", mock_animations): result = list_animations(category="Fighting") + assert isinstance(result, ExtendedDict) + assert isinstance(result["animations"][0]["name"], ExtendedString) assert result["count"] == 1 assert result["animations"][0]["name"] == "Punch" @@ -324,11 +364,31 @@ def test_check_text3d_status(self): task_type="text-to-3d", ) + assert isinstance(result, ExtendedDict) + assert isinstance(result["task_id"], ExtendedString) assert result["task_id"] == "task_123" assert result["status"] == "SUCCEEDED" assert result["progress"] == 100 assert result["model_url"] == "https://example.com/model.glb" + def test_check_text3d_status_accepts_extended_payload(self): + """Task status wrapper should consume the real extended get() payload.""" + from extended_data.connectors.meshy.tools import check_task_status + + mock_result = extend_data({ + "status": "SUCCEEDED", + "progress": 100, + "model_urls": {"glb": "https://example.com/model.glb"}, + }) + + with patch("extended_data.connectors.meshy.text3d.get", return_value=mock_result): + result = check_task_status(task_id="task_123", task_type="text-to-3d") + + assert isinstance(result, ExtendedDict) + assert result["status"] == "SUCCEEDED" + assert result["progress"] == 100 + assert result["model_url"] == "https://example.com/model.glb" + def test_check_unknown_task_type(self): """Test checking unknown task type.""" from extended_data.connectors.meshy.tools import check_task_status @@ -357,6 +417,8 @@ def test_get_existing_animation(self): with patch("extended_data.connectors.meshy.animations.ANIMATIONS", {42: mock_anim}): result = get_animation(animation_id=42) + assert isinstance(result, ExtendedDict) + assert isinstance(result["name"], ExtendedString) assert result["id"] == 42 assert result["name"] == "Dance" assert result["preview_url"] == "https://example.com/preview.gif" @@ -374,7 +436,7 @@ class TestLangChainTools: """Tests for LangChain tools (optional dependency).""" @pytest.mark.skipif( - not pytest.importorskip("langchain_core", reason="langchain-core not installed"), + importlib.util.find_spec("langchain_core") is None, reason="langchain-core not installed", ) def test_get_langchain_tools_returns_structured_tools(self): @@ -415,11 +477,18 @@ def test_get_tools_with_explicit_framework(self): """Test get_tools with explicit framework selection.""" from extended_data.connectors.meshy.tools import get_tools - # Strands/functions always works (no deps) - tools = get_tools("functions") + # Strands always works because it returns plain functions. + tools = get_tools("strands") assert isinstance(tools, list) assert all(callable(t) for t in tools) + def test_get_tools_rejects_functions_alias(self): + """Plain-function tools should use the canonical strands framework name.""" + from extended_data.connectors.meshy.tools import get_tools + + with pytest.raises(ValueError, match="Unknown framework"): + get_tools("functions") + def test_get_tools_invalid_framework(self): """Test get_tools raises ValueError for invalid framework.""" from extended_data.connectors.meshy.tools import get_tools diff --git a/tests/connectors/meshy/test_vector_store.py b/tests/connectors/meshy/test_vector_store.py new file mode 100644 index 0000000..02b33ca --- /dev/null +++ b/tests/connectors/meshy/test_vector_store.py @@ -0,0 +1,169 @@ +"""Tests for Meshy vector store persistence helpers.""" + +from __future__ import annotations + +import sys + +from types import ModuleType +from unittest.mock import patch + +from extended_data.connectors.meshy.persistence import vector_store as vector_store_module +from extended_data.connectors.meshy.persistence.vector_store import VectorStore, get_embedding +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString + + +def test_record_generation_returns_extended_payload(temp_dir) -> None: + """Recording a generation should expose an extended mapping payload.""" + with VectorStore(temp_dir / "assets.db") as store: + result = store.record_generation( + spec_hash="hash-abc", + prompt="cute otter character", + project="project1", + task_id="task-123", + metadata={"source": "test"}, + ) + + assert isinstance(result, ExtendedDict) + assert result["spec_hash"] == "hash-abc" + assert result["prompt"] == "cute otter character" + assert isinstance(result["prompt"], ExtendedString) + assert isinstance(result["metadata"], ExtendedDict) + assert result["metadata"]["source"] == "test" + assert isinstance(result["created_at"], ExtendedString) + + +def test_record_generation_is_idempotent_with_extended_payload(temp_dir) -> None: + """Duplicate spec hashes should return the existing extended payload.""" + with VectorStore(temp_dir / "assets.db") as store: + first = store.record_generation( + spec_hash="hash-abc", + prompt="first prompt", + project="project1", + ) + second = store.record_generation( + spec_hash="hash-abc", + prompt="second prompt", + project="project1", + ) + + assert isinstance(second, ExtendedDict) + assert second["id"] == first["id"] + assert second["prompt"] == "first prompt" + + +def test_get_record_methods_return_extended_payloads(temp_dir) -> None: + """Spec hash and task ID lookups should return extended mapping payloads.""" + with VectorStore(temp_dir / "assets.db") as store: + store.record_generation( + spec_hash="hash-abc", + prompt="cute otter character", + project="project1", + task_id="task-123", + ) + + by_hash = store.get_by_spec_hash("hash-abc") + by_task = store.get_by_task_id("task-123") + + assert isinstance(by_hash, ExtendedDict) + assert by_hash["spec_hash"] == "hash-abc" + assert isinstance(by_task, ExtendedDict) + assert by_task["task_id"] == "task-123" + + +def test_record_metadata_decodes_through_data_boundary(temp_dir, monkeypatch) -> None: + """Persisted metadata should use the shared JSON decoder on reads.""" + with VectorStore(temp_dir / "assets.db") as store: + store.record_generation( + spec_hash="hash-abc", + prompt="cute otter character", + project="project1", + metadata={"source": "test"}, + ) + + def fail_local_json_loads(*_: object) -> object: + raise AssertionError("metadata_json must be decoded through decode_file") + + monkeypatch.setattr(vector_store_module.json, "loads", fail_local_json_loads) + record = store.get_by_spec_hash("hash-abc") + + assert isinstance(record, ExtendedDict) + assert record["metadata"]["source"] == "test" + + +def test_record_metadata_encodes_through_export_boundary(temp_dir) -> None: + """Persisted metadata should use the shared JSON export boundary on writes.""" + with VectorStore(temp_dir / "assets.db") as store: + with patch( + "extended_data.connectors.meshy.persistence.vector_store.wrap_raw_data_for_export", + wraps=vector_store_module.wrap_raw_data_for_export, + ) as mock_wrap_for_export: + store.record_generation( + spec_hash="hash-abc", + prompt="cute otter character", + project="project1", + metadata={"source": "test"}, + ) + + mock_wrap_for_export.assert_called_once_with({"source": "test"}, allow_encoding="json") + + +def test_search_text_and_list_pending_return_extended_payloads(temp_dir) -> None: + """Search and pending queries should return extended lists of mappings.""" + with VectorStore(temp_dir / "assets.db") as store: + store.record_generation( + spec_hash="hash-otter", + prompt="cute otter character", + project="project1", + ) + store.record_generation( + spec_hash="hash-badger", + prompt="armored badger character", + project="project2", + ) + store.update_status("hash-badger", "SUCCEEDED") + + search_results = store.search_text("otter") + pending_results = store.list_pending(project="project1") + + assert isinstance(search_results, ExtendedList) + assert len(search_results) == 1 + assert isinstance(search_results[0], ExtendedDict) + assert search_results[0]["spec_hash"] == "hash-otter" + + assert isinstance(pending_results, ExtendedList) + assert len(pending_results) == 1 + assert isinstance(pending_results[0]["prompt"], ExtendedString) + assert pending_results[0]["project"] == "project1" + + +def test_search_similar_without_vector_extension_returns_extended_list(temp_dir, monkeypatch) -> None: + """The no-vector fallback should still expose an extended list.""" + monkeypatch.setattr(vector_store_module, "_HAS_VECTOR", False) + + with VectorStore(temp_dir / "assets.db") as store: + result = store.search_similar([0.0] * store.embedding_dim) + + assert isinstance(result, ExtendedList) + assert result == [] + + +def test_get_embedding_returns_extended_vector(monkeypatch) -> None: + """Embedding helper should promote vectors when the optional encoder exists.""" + + class _FakeEmbedding: + def tolist(self) -> list[float]: + return [0.1, 0.2, 0.3] + + class _FakeEncoder: + def encode(self, text: str) -> _FakeEmbedding: + assert text == "cute otter" + return _FakeEmbedding() + + module = ModuleType("sentence_transformers") + module.SentenceTransformer = lambda model: _FakeEncoder() # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "sentence_transformers", module) + + result = get_embedding("cute otter") + + assert isinstance(result, ExtendedList) + assert result == [0.1, 0.2, 0.3] diff --git a/tests/connectors/meshy/test_webhooks.py b/tests/connectors/meshy/test_webhooks.py index 3bf9d91..fde430f 100644 --- a/tests/connectors/meshy/test_webhooks.py +++ b/tests/connectors/meshy/test_webhooks.py @@ -2,6 +2,11 @@ from __future__ import annotations +import base64 +import hashlib +import hmac +import json + from datetime import datetime, timezone from unittest.mock import MagicMock, patch @@ -17,6 +22,16 @@ WebhookModelUrls, WebhookRiggingResult, ) +from extended_data.containers import ExtendedDict, ExtendedString, extend_data + + +def _task_lookup_payload(project: str, spec_hash: str, asset_manifest: AssetManifest) -> ExtendedDict: + """Build the repository task lookup payload shape.""" + return extend_data({ + "project": project, + "spec_hash": spec_hash, + "asset": asset_manifest.model_dump(mode="json"), + }) class TestMeshyWebhookPayload: @@ -91,6 +106,8 @@ def test_get_all_urls(self): thumbnail_url="https://example.com/thumb.png", ) urls = payload.get_all_urls() + assert isinstance(urls, ExtendedDict) + assert isinstance(urls["glb"], ExtendedString) assert urls["glb"] == "https://example.com/model.glb" assert urls["fbx"] == "https://example.com/model.fbx" assert urls["thumbnail"] == "https://example.com/thumb.png" @@ -122,7 +139,7 @@ def mock_repository(self, temp_dir): ], ) - repo.find_task_by_id.return_value = ("project1", "hash-abc123", asset_manifest) + repo.find_task_by_id.return_value = _task_lookup_payload("project1", "hash-abc123", asset_manifest) repo.record_task_update.return_value = None return repo @@ -144,6 +161,8 @@ def test_handle_webhook_success(self, webhook_handler, mock_repository, webhook_ payload = MeshyWebhookPayload(**webhook_payload_succeeded) result = webhook_handler.handle_webhook(payload) + assert isinstance(result, ExtendedDict) + assert isinstance(result["status"], ExtendedString) assert result["status"] == "success" assert result["task_id"] == "task-12345-abcde" assert result["project"] == "project1" @@ -163,6 +182,8 @@ def test_handle_webhook_task_not_found(self, webhook_handler, mock_repository): ) result = webhook_handler.handle_webhook(payload) + assert isinstance(result, ExtendedDict) + assert isinstance(result["message"], ExtendedString) assert result["status"] == "error" assert "not found" in result["message"] @@ -184,11 +205,12 @@ def test_handle_webhook_failed_task(self, webhook_handler, mock_repository, webh ) ], ) - mock_repository.find_task_by_id.return_value = ("project1", "hash-xyz", asset_manifest) + mock_repository.find_task_by_id.return_value = _task_lookup_payload("project1", "hash-xyz", asset_manifest) payload = MeshyWebhookPayload(**webhook_payload_failed) result = webhook_handler.handle_webhook(payload) + assert isinstance(result, ExtendedDict) assert result["status"] == "success" # Handler succeeded assert result["task_status"] == "FAILED" # Task failed @@ -196,6 +218,39 @@ def test_handle_webhook_failed_task(self, webhook_handler, mock_repository, webh call_args = mock_repository.record_task_update.call_args assert call_args[1]["error"] == "Generation failed due to invalid prompt" + def test_handle_webhook_redacts_failed_task_error(self, webhook_handler, mock_repository): + """Failed task errors recorded from webhook payloads should be redacted.""" + asset_manifest = AssetManifest( + asset_spec_hash="hash-xyz", + spec_fingerprint="hash-xyz", + project="project1", + asset_intent="creature", + task_graph=[ + TaskGraphEntry( + task_id="task-failed-secret", + service="text3d", + status="IN_PROGRESS", + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + ], + ) + mock_repository.find_task_by_id.return_value = _task_lookup_payload("project1", "hash-xyz", asset_manifest) + + payload = MeshyWebhookPayload( + id="task-failed-secret", + status="FAILED", + created_at=1700000000, + task_error={"message": "denied password=hunter2 Authorization: Bearer raw_token"}, + ) + + webhook_handler.handle_webhook(payload) + + error = mock_repository.record_task_update.call_args.kwargs["error"] + assert "hunter2" not in error + assert "raw_token" not in error + assert "[REDACTED]" in error + def test_handle_webhook_downloads_artifact(self, temp_dir, webhook_payload_succeeded): """Test that handler downloads artifacts on success.""" from pathlib import Path @@ -223,7 +278,7 @@ def test_handle_webhook_downloads_artifact(self, temp_dir, webhook_payload_succe ) ], ) - mock_repository.find_task_by_id.return_value = ("project1", "hash-abc123", asset_manifest) + mock_repository.find_task_by_id.return_value = _task_lookup_payload("project1", "hash-abc123", asset_manifest) mock_repository.record_task_update.return_value = None def mock_download(url, output_path): @@ -243,6 +298,7 @@ def mock_download(url, output_path): payload = MeshyWebhookPayload(**webhook_payload_succeeded) result = handler.handle_webhook(payload) + assert isinstance(result, ExtendedDict) assert result["artifacts_downloaded"] == 1 mock_base.download.assert_called_once() @@ -257,12 +313,88 @@ def test_handle_webhook_no_download_when_disabled(self, mock_repository, webhook payload = MeshyWebhookPayload(**webhook_payload_succeeded) result = handler.handle_webhook(payload) + assert isinstance(result, ExtendedDict) assert result["artifacts_downloaded"] == 0 mock_base.download.assert_not_called() - def test_verify_signature_stub(self, webhook_handler): - """Test that signature verification stub returns True.""" - assert webhook_handler.verify_signature(b"payload", "signature") is True + def test_verify_signature_requires_secret(self, webhook_handler): + """Unsigned handlers reject signatures instead of accepting placeholders.""" + payload = b'{"id":"task-12345-abcde"}' + signature = hmac.new(b"secret", payload, hashlib.sha256).hexdigest() + + assert webhook_handler.verify_signature(payload, signature) is False + + def test_verify_signature_accepts_hmac_sha256_hex(self, mock_repository): + """Verify raw payloads with HMAC-SHA256 hex signatures.""" + payload = b'{"id":"task-12345-abcde"}' + signature = hmac.new(b"secret", payload, hashlib.sha256).hexdigest() + handler = WebhookHandler(repository=mock_repository, webhook_secret="secret") + + assert handler.verify_signature(payload, signature) is True + assert handler.verify_signature(payload, f"sha256={signature.upper()}") is True + assert handler.verify_signature(b'{"id":"tampered"}', signature) is False + assert handler.verify_signature(payload, "not-a-signature") is False + + def test_verify_signature_accepts_hmac_sha256_base64(self, mock_repository): + """Verify raw payloads with HMAC-SHA256 base64 signatures.""" + payload = b'{"id":"task-12345-abcde"}' + digest = hmac.new(b"secret", payload, hashlib.sha256).digest() + signature = base64.b64encode(digest).decode("ascii") + handler = WebhookHandler(repository=mock_repository, webhook_secret=b"secret") + + assert handler.verify_signature(payload, signature) is True + + def test_handle_signed_webhook_rejects_invalid_signature( + self, mock_repository, webhook_payload_succeeded + ): + """Invalid signatures fail before payload parsing or repository mutation.""" + payload = json.dumps(webhook_payload_succeeded, separators=(",", ":")).encode("utf-8") + handler = WebhookHandler(repository=mock_repository, webhook_secret="secret") + + result = handler.handle_signed_webhook(payload, "invalid") + + assert isinstance(result, ExtendedDict) + assert result == { + "status": "error", + "message": "Invalid webhook signature", + } + mock_repository.find_task_by_id.assert_not_called() + mock_repository.record_task_update.assert_not_called() + + def test_handle_signed_webhook_redacts_invalid_payload_error(self, mock_repository): + """Signed payload parse failures should not echo secret-bearing input.""" + payload = b'{"id":"task-12345-abcde","password":"hunter2","authorization":"Bearer raw_token"}' + signature = hmac.new(b"secret", payload, hashlib.sha256).hexdigest() + handler = WebhookHandler(repository=mock_repository, webhook_secret="secret") + + result = handler.handle_signed_webhook(payload, signature) + + assert result["status"] == "error" + assert result["message"] == "Invalid webhook payload" + assert "hunter2" not in result["error"] + assert "raw_token" not in result["error"] + assert "[REDACTED]" in result["error"] + mock_repository.find_task_by_id.assert_not_called() + mock_repository.record_task_update.assert_not_called() + + def test_handle_signed_webhook_processes_valid_signature( + self, mock_repository, webhook_payload_succeeded + ): + """Valid signed raw payloads are parsed and processed.""" + payload = json.dumps(webhook_payload_succeeded, separators=(",", ":")).encode("utf-8") + signature = hmac.new(b"secret", payload, hashlib.sha256).hexdigest() + handler = WebhookHandler( + repository=mock_repository, + download_artifacts=False, + webhook_secret="secret", + ) + + result = handler.handle_signed_webhook(payload, signature) + + assert isinstance(result, ExtendedDict) + assert result["status"] == "success" + assert result["task_id"] == "task-12345-abcde" + mock_repository.record_task_update.assert_called_once() class TestWebhookHandlerArtifactDownload: diff --git a/tests/connectors/test_ai_tools.py b/tests/connectors/test_ai_tools.py index 9108683..f944936 100644 --- a/tests/connectors/test_ai_tools.py +++ b/tests/connectors/test_ai_tools.py @@ -4,6 +4,8 @@ from pydantic import BaseModel, Field +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString + class TestGetPydanticSchema: """Tests for get_pydantic_schema function.""" @@ -20,6 +22,10 @@ class MyTool(BaseModel): schema = get_pydantic_schema(MyTool) + assert isinstance(schema, ExtendedDict) + assert isinstance(schema["properties"], ExtendedDict) + assert isinstance(schema["required"], ExtendedList) + assert isinstance(schema["type"], ExtendedString) assert schema == { "type": "object", "properties": { @@ -41,6 +47,7 @@ class MyTool(BaseModel): schema = get_pydantic_schema(MyTool) + assert isinstance(schema, ExtendedDict) assert schema == { "type": "object", "properties": { @@ -70,6 +77,7 @@ class MyTool(BaseModel): schema = get_pydantic_schema(MyTool) + assert isinstance(schema, ExtendedDict) assert schema == { "type": "object", "properties": { diff --git a/tests/connectors/test_anthropic.py b/tests/connectors/test_anthropic.py index 5332a81..ac63aba 100644 --- a/tests/connectors/test_anthropic.py +++ b/tests/connectors/test_anthropic.py @@ -6,10 +6,13 @@ from unittest.mock import MagicMock, patch +import httpx import pytest from extended_data.connectors.anthropic import ( CLAUDE_MODELS, + AnthropicAPIError, + AnthropicAuthError, AnthropicConnector, AnthropicError, ContentBlock, @@ -18,6 +21,26 @@ Model, Usage, ) +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString, extend_data + + +def _json_response(payload: object, status_code: int = 200) -> httpx.Response: + """Build an HTTPX response whose JSON must be read from content bytes.""" + response = httpx.Response(status_code, json=payload) + response.json = MagicMock(side_effect=AssertionError("Anthropic responses must be decoded from content bytes")) + return response + + +def _text_response(text: str, status_code: int = 200) -> httpx.Response: + """Build an HTTPX response with invalid/non-JSON body text.""" + response = httpx.Response(status_code, content=text.encode()) + response.json = MagicMock(side_effect=AssertionError("Anthropic responses must be decoded from content bytes")) + return response + + +def _logged_text(logger: MagicMock) -> str: + """Return concatenated mock logger messages.""" + return "\n".join(str(arg) for call in logger.method_calls for arg in call.args) class TestModels: @@ -101,11 +124,12 @@ def test_is_available_false(self): assert AnthropicConnector.is_available() is False def test_get_available_models(self): - """get_available_models should return model dictionary.""" + """get_available_models should return extended model metadata.""" models = AnthropicConnector.get_available_models() assert "claude-sonnet-4-20250514" in models assert "claude-opus-4-20250514" in models - assert isinstance(models, dict) + assert isinstance(models, ExtendedDict) + assert isinstance(models["claude-sonnet-4-20250514"], ExtendedString) def test_validate_model(self): """validate_model should check against known models.""" @@ -123,6 +147,7 @@ def test_get_recommended_model(self): with patch.object(httpx, "Client"): connector = AnthropicConnector(api_key="test-key") # Using verified model IDs from https://docs.anthropic.com/en/docs/about-claude/models + assert isinstance(connector.get_recommended_model("general"), ExtendedString) assert connector.get_recommended_model("general") == "claude-sonnet-4-5-20250929" assert connector.get_recommended_model("fast") == "claude-haiku-4-5-20251001" assert connector.get_recommended_model("powerful") == "claude-opus-4-5-20251101" @@ -133,10 +158,7 @@ def test_create_message(self): mock_client = MagicMock() - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.is_success = True - mock_response.json.return_value = { + mock_response = _json_response({ "id": "msg_123", "type": "message", "role": "assistant", @@ -144,7 +166,7 @@ def test_create_message(self): "model": "claude-sonnet-4-20250514", "stop_reason": "end_turn", "usage": {"input_tokens": 10, "output_tokens": 5}, - } + }) mock_client.request.return_value = mock_response with patch.object(httpx, "Client", return_value=mock_client): @@ -152,14 +174,18 @@ def test_create_message(self): message = connector.create_message( model="claude-sonnet-4-20250514", max_tokens=1024, - messages=[{"role": "user", "content": "Hi"}], + messages=extend_data([{"role": "user", "content": "Hi"}]), ) - assert message.id == "msg_123" - assert message.role == MessageRole.ASSISTANT - assert message.text == "Hello!" - assert message.usage.input_tokens == 10 - assert message.usage.output_tokens == 5 + assert isinstance(message, ExtendedDict) + assert isinstance(message["content"], ExtendedList) + assert isinstance(message["content"][0], ExtendedDict) + assert isinstance(message["id"], ExtendedString) + assert message["id"] == "msg_123" + assert message["role"] == "assistant" + assert message["content"][0]["text"] == "Hello!" + assert message["usage"]["input_tokens"] == 10 + assert message["usage"]["output_tokens"] == 5 # Verify request call_args = mock_client.request.call_args @@ -167,6 +193,8 @@ def test_create_message(self): assert "/v1/messages" in call_args.args[1] assert call_args.kwargs["json"]["model"] == "claude-sonnet-4-20250514" assert call_args.kwargs["json"]["max_tokens"] == 1024 + assert isinstance(call_args.kwargs["json"]["messages"], list) + assert isinstance(call_args.kwargs["json"]["messages"][0], dict) def test_create_message_with_system(self): """create_message should include system prompt.""" @@ -174,17 +202,14 @@ def test_create_message_with_system(self): mock_client = MagicMock() - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.is_success = True - mock_response.json.return_value = { + mock_response = _json_response({ "id": "msg_123", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "Hello!"}], "model": "claude-sonnet-4-20250514", "usage": {"input_tokens": 10, "output_tokens": 5}, - } + }) mock_client.request.return_value = mock_response with patch.object(httpx, "Client", return_value=mock_client): @@ -205,23 +230,185 @@ def test_list_models(self): mock_client = MagicMock() - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.is_success = True - mock_response.json.return_value = { + mock_response = _json_response({ "data": [ {"id": "claude-sonnet-4-20250514", "display_name": "Claude Sonnet 4"}, {"id": "claude-opus-4-20250514", "display_name": "Claude Opus 4"}, ] - } + }) mock_client.request.return_value = mock_response with patch.object(httpx, "Client", return_value=mock_client): connector = AnthropicConnector(api_key="test-key") models = connector.list_models() + assert isinstance(models, ExtendedList) + assert isinstance(models[0], ExtendedDict) + assert isinstance(models[0]["id"], ExtendedString) assert len(models) == 2 - assert models[0].id == "claude-sonnet-4-20250514" + assert models[0]["id"] == "claude-sonnet-4-20250514" + + def test_get_model(self): + """get_model should return an extended model payload.""" + import httpx + + mock_client = MagicMock() + + mock_response = _json_response({"id": "claude-sonnet-4-20250514", "display_name": "Claude Sonnet 4"}) + mock_client.request.return_value = mock_response + + with patch.object(httpx, "Client", return_value=mock_client): + connector = AnthropicConnector(api_key="test-key") + model = connector.get_model("claude-sonnet-4-20250514") + + assert isinstance(model, ExtendedDict) + assert isinstance(model["display_name"], ExtendedString) + assert model["display_name"] == "Claude Sonnet 4" + + def test_count_tokens_returns_vendor_token_count(self): + """count_tokens should return the explicit Anthropic response value.""" + import httpx + + mock_client = MagicMock() + mock_response = _json_response({"input_tokens": 42}) + mock_client.request.return_value = mock_response + + with patch.object(httpx, "Client", return_value=mock_client): + connector = AnthropicConnector(api_key="test-key") + assert connector.count_tokens(model="claude-sonnet-4-20250514", messages=[]) == 42 + + @pytest.mark.parametrize( + ("method_name", "call", "payload"), + [ + ( + "create_message", + lambda connector: connector.create_message( + model="claude-sonnet-4-20250514", + max_tokens=1024, + messages=[{"role": "user", "content": "Hi"}], + ), + {"role": "assistant", "password": "hunter2", "authorization": "Bearer raw_token"}, + ), + ( + "list_models", + lambda connector: connector.list_models(), + {"data": [{"id": "claude-sonnet-4-20250514", "api_key": "key_123"}]}, + ), + ( + "get_model", + lambda connector: connector.get_model("claude-sonnet-4-20250514"), + {"id": "claude-sonnet-4-20250514", "client_secret": "secret_123"}, + ), + ( + "count_tokens", + lambda connector: connector.count_tokens(model="claude-sonnet-4-20250514", messages=[]), + {"password": "hunter2", "authorization": "Bearer raw_token"}, + ), + ], + ) + def test_success_response_validation_errors_are_redacted(self, method_name, call, payload): + """Malformed success payloads should fail loudly without raw Pydantic details.""" + import httpx + + mock_client = MagicMock() + mock_response = _json_response(payload) + mock_client.request.return_value = mock_response + + with patch.object(httpx, "Client", return_value=mock_client): + connector = AnthropicConnector(api_key="test-key") + with pytest.raises(AnthropicAPIError) as exc_info: + call(connector) + + message = str(exc_info.value) + assert exc_info.value.error_type == "unexpected_response" + assert method_name in message + for raw_secret in ["hunter2", "raw_token", "key_123", "secret_123"]: + assert raw_secret not in message + assert "ValidationError" not in message + assert "[REDACTED]" in message + + def test_success_response_json_errors_are_redacted(self): + """Malformed JSON diagnostics should not expose raw parser exception values.""" + import httpx + + mock_client = MagicMock() + mock_response = _text_response("bad password=hunter2 Authorization: Bearer raw_token") + mock_client.request.return_value = mock_response + + with patch.object(httpx, "Client", return_value=mock_client): + connector = AnthropicConnector(api_key="test-key") + with pytest.raises(AnthropicAPIError) as exc_info: + connector.get_model("claude-sonnet-4-20250514") + + message = str(exc_info.value) + assert "hunter2" not in message + assert "raw_token" not in message + assert "[REDACTED]" in message + + def test_handle_error_redacts_sensitive_vendor_message(self): + """Anthropic errors should preserve status metadata without leaking secrets.""" + import httpx + + connector = AnthropicConnector(api_key="test-key") + response = httpx.Response( + 401, + json={"error": {"type": "auth_error", "message": "denied password=hunter2 Bearer raw_token"}}, + ) + + with pytest.raises(AnthropicAuthError) as exc_info: + connector._handle_error(response) + + message = str(exc_info.value) + assert exc_info.value.status_code == 401 + assert exc_info.value.error_type == "auth_error" + assert "hunter2" not in message + assert "raw_token" not in message + assert "[REDACTED]" in message + + def test_execute_agent_task_redacts_error_result(self): + """Agent task failures should not expose secrets in public result errors.""" + import httpx + + with patch.object(httpx, "Client"): + connector = AnthropicConnector(api_key="test-key") + + with patch.object( + connector, + "create_message", + side_effect=AnthropicError("failed password=hunter2 Authorization: Bearer raw_token"), + ): + result = connector.execute_agent_task("summarize") + + assert result.success is False + assert result.error is not None + assert "hunter2" not in result.error + assert "raw_token" not in result.error + assert "[REDACTED]" in result.error + + def test_execute_agent_task_does_not_log_task_prompt(self, base_connector_kwargs): + """Agent task diagnostics should not expose raw prompt text.""" + import httpx + + with patch.object(httpx, "Client"): + connector = AnthropicConnector(api_key="test-key", **base_connector_kwargs) + + with patch.object( + connector, + "create_message", + return_value=extend_data( + { + "content": [{"type": "text", "text": "done"}], + "usage": {"input_tokens": 2, "output_tokens": 1}, + } + ), + ): + result = connector.execute_agent_task("rotate password=hunter2 for customer-prod") + + logs = _logged_text(connector.logger) + assert result.success is True + assert "hunter2" not in logs + assert "customer-prod" not in logs + assert "Executing agent task with" in logs class TestClaudeModels: diff --git a/tests/connectors/test_anthropic_tools.py b/tests/connectors/test_anthropic_tools.py index bfab7b1..40309c3 100644 --- a/tests/connectors/test_anthropic_tools.py +++ b/tests/connectors/test_anthropic_tools.py @@ -4,6 +4,8 @@ from unittest.mock import MagicMock, patch +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString, extend_data + def test_anthropic_list_models(): """Test list_models tool.""" @@ -11,13 +13,15 @@ def test_anthropic_list_models(): with patch("extended_data.connectors.anthropic.AnthropicConnector") as mock_connector_class: mock_connector = MagicMock() - mock_model = MagicMock() - mock_model.id = "claude-3-opus" - mock_model.display_name = "Claude 3 Opus" - mock_connector.list_models.return_value = [mock_model] + mock_connector.list_models.return_value = extend_data( + [{"id": "claude-3-opus", "display_name": "Claude 3 Opus"}] + ) mock_connector_class.return_value = mock_connector result = anthropic_list_models() + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) + assert isinstance(result[0]["id"], ExtendedString) assert len(result) == 1 assert result[0]["id"] == "claude-3-opus" @@ -28,15 +32,19 @@ def test_anthropic_create_message(): with patch("extended_data.connectors.anthropic.AnthropicConnector") as mock_connector_class: mock_connector = MagicMock() - mock_response = MagicMock() - mock_response.id = "msg_123" - mock_response.text = "Hello!" - mock_response.model = "claude-3-opus" - mock_response.usage.input_tokens = 10 - mock_response.usage.output_tokens = 5 - mock_connector.create_message.return_value = mock_response + mock_connector.create_message.return_value = extend_data( + { + "id": "msg_123", + "content": [{"type": "text", "text": "Hello!"}], + "model": "claude-3-opus", + "usage": {"input_tokens": 10, "output_tokens": 5}, + } + ) mock_connector_class.return_value = mock_connector result = anthropic_create_message(model="claude-3-opus", prompt="Hi") + assert isinstance(result, ExtendedDict) + assert isinstance(result["text"], ExtendedString) + assert isinstance(result["usage"], ExtendedDict) assert result["id"] == "msg_123" assert result["text"] == "Hello!" diff --git a/tests/connectors/test_aws_codedeploy.py b/tests/connectors/test_aws_codedeploy.py index e20fecb..61c3423 100644 --- a/tests/connectors/test_aws_codedeploy.py +++ b/tests/connectors/test_aws_codedeploy.py @@ -1,3 +1,4 @@ +# ruff: noqa: I001 """Tests for the AWS CodeDeploy helper module.""" from __future__ import annotations @@ -6,21 +7,35 @@ import pytest +pytest.importorskip("botocore") + from botocore.exceptions import ClientError, WaiterError +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString from extended_data.connectors.aws.codedeploy import ( create_codedeploy_deployment, get_aws_codedeploy_deployments, ) -def _client_error(operation: str) -> ClientError: +def _client_error(operation: str, message: str = "denied") -> ClientError: return ClientError( - error_response={"Error": {"Code": "AccessDenied", "Message": "denied"}}, + error_response={"Error": {"Code": "AccessDenied", "Message": message}}, operation_name=operation, ) +def _logged_text(logger: MagicMock) -> str: + """Return concatenated mock logger messages.""" + return "\n".join(str(arg) for call in logger.method_calls for arg in call.args) + + +def _logging_adapter() -> MagicMock: + adapter = MagicMock() + adapter.logger = MagicMock() + return adapter + + class TestGetAwsCodeDeployDeployments: def test_returns_details_and_normalizes_statuses(self): codedeploy_client = MagicMock() @@ -43,6 +58,10 @@ def test_returns_details_and_normalizes_statuses(self): codedeploy_client=codedeploy_client, ) + assert isinstance(result, ExtendedDict) + assert isinstance(result["deployment_ids"], ExtendedList) + assert isinstance(result["deployment_ids"][0], ExtendedString) + assert isinstance(result["deployments"][0], ExtendedDict) assert result["deployment_ids"] == ["dep-1", "dep-2", "dep-3"] assert [item["deploymentId"] for item in result["deployments"]] == ["dep-1", "dep-2", "dep-3"] @@ -51,10 +70,31 @@ def test_returns_details_and_normalizes_statuses(self): def test_raises_runtime_error_on_client_failure(self): codedeploy_client = MagicMock() - codedeploy_client.list_deployments.side_effect = _client_error("ListDeployments") + codedeploy_client.list_deployments.side_effect = _client_error( + "ListDeployments", + "denied private-app prod-group token-private tag-private token=raw-token", + ) + logging_adapter = _logging_adapter() + + with pytest.raises(RuntimeError) as exc_info: + get_aws_codedeploy_deployments( + application_name="private-app", + deployment_group_name="prod-group", + next_token="token-private", + tag_filters=[{"Value": "tag-private"}], + codedeploy_client=codedeploy_client, + logging_adapter=logging_adapter, + ) - with pytest.raises(RuntimeError): - get_aws_codedeploy_deployments(codedeploy_client=codedeploy_client) + diagnostics = _logged_text(logging_adapter.logger) + str(exc_info.value) + assert "private-app" not in diagnostics + assert "prod-group" not in diagnostics + assert "token-private" not in diagnostics + assert "tag-private" not in diagnostics + assert "raw-token" not in diagnostics + assert "[REDACTED]" in diagnostics + assert exc_info.value.__cause__ is None + assert all("exc_info" not in logged_call.kwargs for logged_call in logging_adapter.logger.method_calls) class TestCreateCodeDeployDeployment: @@ -79,6 +119,9 @@ def test_waits_for_success_and_returns_details(self): codedeploy_client=codedeploy_client, ) + assert isinstance(result, ExtendedDict) + assert isinstance(result["deployment_id"], ExtendedString) + assert isinstance(result["deployment_info"], ExtendedDict) assert result["deployment_id"] == "dep-123" assert result["status"] == "Succeeded" waiter.wait.assert_called_once_with( @@ -88,10 +131,11 @@ def test_waits_for_success_and_returns_details(self): def test_waiter_failure_raises_runtime_error(self): codedeploy_client = MagicMock() - codedeploy_client.create_deployment.return_value = {"deploymentId": "dep-456"} + codedeploy_client.create_deployment.return_value = {"deploymentId": "dep-sensitive"} codedeploy_client.get_deployment.return_value = { - "deploymentInfo": {"deploymentId": "dep-456", "status": "Failed"} + "deploymentInfo": {"deploymentId": "dep-sensitive", "status": "Failed"} } + logging_adapter = _logging_adapter() waiter = MagicMock() waiter.wait.side_effect = WaiterError( @@ -101,7 +145,7 @@ def test_waiter_failure_raises_runtime_error(self): ) codedeploy_client.get_waiter.return_value = waiter - with pytest.raises(RuntimeError): + with pytest.raises(RuntimeError) as exc_info: create_codedeploy_deployment( application_name="app", deployment_group_name="group", @@ -111,8 +155,75 @@ def test_waiter_failure_raises_runtime_error(self): }, wait=True, codedeploy_client=codedeploy_client, + logging_adapter=logging_adapter, + ) + + diagnostics = _logged_text(logging_adapter.logger) + str(exc_info.value) + assert "dep-sensitive" not in diagnostics + assert "[REDACTED]" in diagnostics + assert exc_info.value.__cause__ is None + + def test_detail_fetch_failure_logs_redact_deployment_id(self): + """Detail hydration failures should not log deployment identifiers or raw provider messages.""" + codedeploy_client = MagicMock() + codedeploy_client.create_deployment.return_value = {"deploymentId": "dep-sensitive"} + codedeploy_client.get_deployment.side_effect = _client_error( + "GetDeployment", + "denied for dep-sensitive token=raw-token", + ) + logging_adapter = _logging_adapter() + + result = create_codedeploy_deployment( + application_name="app", + deployment_group_name="group", + revision={ + "revisionType": "S3", + "s3Location": {"bucket": "bucket", "key": "bundle.zip", "bundleType": "zip"}, + }, + wait=False, + include_details=True, + codedeploy_client=codedeploy_client, + logging_adapter=logging_adapter, + ) + + assert result["deployment_id"] == "dep-sensitive" + logs = _logged_text(logging_adapter.logger) + assert "[REDACTED]" in logs + assert "dep-sensitive" not in logs + assert "raw-token" not in logs + assert all("exc_info" not in logged_call.kwargs for logged_call in logging_adapter.logger.method_calls) + + def test_create_failure_redacts_request_context(self): + """Create failures should redact app/group/revision identifiers from diagnostics.""" + codedeploy_client = MagicMock() + codedeploy_client.create_deployment.side_effect = _client_error( + "CreateDeployment", + "denied private-app prod-group prod-bucket bundle.zip secret=raw-secret", + ) + logging_adapter = _logging_adapter() + + with pytest.raises(RuntimeError) as exc_info: + create_codedeploy_deployment( + application_name="private-app", + deployment_group_name="prod-group", + revision={ + "revisionType": "S3", + "s3Location": {"bucket": "prod-bucket", "key": "bundle.zip", "bundleType": "zip"}, + }, + codedeploy_client=codedeploy_client, + logging_adapter=logging_adapter, ) + diagnostics = _logged_text(logging_adapter.logger) + str(exc_info.value) + assert "private-app" not in diagnostics + assert "prod-group" not in diagnostics + assert "prod-bucket" not in diagnostics + assert "bundle.zip" not in diagnostics + assert "raw-secret" not in diagnostics + assert "[REDACTED]" in diagnostics + assert exc_info.value.__cause__ is None + assert all("exc_info" not in logged_call.kwargs for logged_call in logging_adapter.logger.method_calls) + def test_validates_file_exists_behavior(self): codedeploy_client = MagicMock() diff --git a/tests/connectors/test_aws_connector.py b/tests/connectors/test_aws_connector.py index bdbc621..a7b9894 100644 --- a/tests/connectors/test_aws_connector.py +++ b/tests/connectors/test_aws_connector.py @@ -1,3 +1,4 @@ +# ruff: noqa: I001 """Tests for AWSConnector.""" from __future__ import annotations @@ -6,11 +7,20 @@ import pytest +pytest.importorskip("boto3") +pytest.importorskip("botocore") + from botocore.exceptions import ClientError +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString, extend_data from extended_data.connectors.aws import AWSConnector +def _logged_text(logger: MagicMock) -> str: + """Return concatenated mock logger messages.""" + return "\n".join(str(arg) for call in logger.method_calls for arg in call.args) + + class TestAWSConnector: """Test suite for AWSConnector.""" @@ -55,9 +65,11 @@ def test_assume_role_success(self, mock_session_class, base_connector_kwargs): @patch("extended_data.connectors.aws.boto3.Session") def test_assume_role_failure(self, mock_session_class, base_connector_kwargs): """Test failed role assumption.""" + role_arn = "arn:aws:iam::123456789012:role/TestRole" mock_sts_client = MagicMock() mock_sts_client.assume_role.side_effect = ClientError( - {"Error": {"Code": "AccessDenied", "Message": "Not authorized"}}, "AssumeRole" + {"Error": {"Code": "AccessDenied", "Message": f"Not authorized for {role_arn} token=raw-token"}}, + "AssumeRole", ) mock_default_session = MagicMock() @@ -67,11 +79,16 @@ def test_assume_role_failure(self, mock_session_class, base_connector_kwargs): connector = AWSConnector(**base_connector_kwargs) connector.default_aws_session = mock_default_session - role_arn = "arn:aws:iam::123456789012:role/TestRole" - - with pytest.raises(RuntimeError, match="Failed to assume role"): + with pytest.raises(RuntimeError, match="Failed to assume role") as exc_info: connector.assume_role(role_arn, "test-session") + diagnostics = _logged_text(connector.logger) + str(exc_info.value) + assert role_arn not in diagnostics + assert "raw-token" not in diagnostics + assert "[REDACTED]" in diagnostics + assert exc_info.value.__cause__ is None + assert all("exc_info" not in logged_call.kwargs for logged_call in connector.logger.method_calls) + def test_get_aws_session_default(self, base_connector_kwargs): """Test getting default AWS session.""" connector = AWSConnector(**base_connector_kwargs) @@ -116,6 +133,30 @@ def test_get_aws_resource(self, mock_session_class, base_connector_kwargs): assert resource == mock_resource mock_session.resource.assert_called_once() + @patch("extended_data.connectors.aws.boto3.Session") + def test_get_aws_resource_failure_redacts_exception_context(self, mock_session_class, base_connector_kwargs): + """Resource creation failures should not chain raw provider exceptions into diagnostics.""" + mock_session = MagicMock() + mock_session.resource.side_effect = ClientError( + {"Error": {"Code": "AccessDenied", "Message": "denied for arn:role/private token=raw-token"}}, + "CreateResource", + ) + mock_session_class.return_value = mock_session + + connector = AWSConnector(**base_connector_kwargs) + connector.default_aws_session = mock_session + connector.get_aws_session = MagicMock(return_value=mock_session) + + with pytest.raises(RuntimeError) as exc_info: + connector.get_aws_resource("s3", execution_role_arn="arn:role/private") + + diagnostics = _logged_text(connector.logger) + str(exc_info.value) + assert "arn:role/private" not in diagnostics + assert "raw-token" not in diagnostics + assert "[REDACTED]" in diagnostics + assert exc_info.value.__cause__ is None + assert all("exc_info" not in logged_call.kwargs for logged_call in connector.logger.method_calls) + def test_list_secrets_returns_arns_with_filters(self, base_connector_kwargs): """Ensure listing secrets returns ARNs when not fetching values.""" connector = AWSConnector(**base_connector_kwargs) @@ -133,8 +174,10 @@ def test_list_secrets_returns_arns_with_filters(self, base_connector_kwargs): connector.get_aws_client = MagicMock(return_value=mock_secretsmanager) filters = [{"Key": "description", "Values": ["prod"]}] - secrets = connector.list_secrets(filters=filters, name_prefix="/vendors/") + secrets = connector.list_secrets(filters=filters, prefix="/vendors/") + assert isinstance(secrets, ExtendedDict) + assert isinstance(secrets["/vendors/foo"], ExtendedString) assert secrets == {"/vendors/foo": "arn:foo", "/vendors/bar": "arn:bar"} connector.get_aws_client.assert_called_once_with( client_name="secretsmanager", @@ -178,6 +221,8 @@ def test_list_secrets_fetches_values_and_skips_empty(self, base_connector_kwargs role_session_name="session", ) + assert isinstance(secrets, ExtendedDict) + assert isinstance(secrets["secret/a"], ExtendedString) assert secrets == {"secret/a": "value-a", "secret/c": "value-c"} connector.get_aws_client.assert_called_once_with( client_name="secretsmanager", @@ -209,21 +254,77 @@ def test_list_secrets_fetches_values_and_skips_empty(self, base_connector_kwargs ) def test_list_secrets_rejects_path_traversal(self, base_connector_kwargs): - """Ensure list_secrets rejects path traversal in name_prefix.""" + """Ensure list_secrets rejects path traversal in prefix.""" import pytest connector = AWSConnector(**base_connector_kwargs) # Should reject path traversal attempts with pytest.raises(ValueError, match="invalid characters"): - connector.list_secrets(name_prefix="../../../etc/passwd") + connector.list_secrets(prefix="../../../etc/passwd") with pytest.raises(ValueError, match="invalid characters"): - connector.list_secrets(name_prefix="secrets/../admin") + connector.list_secrets(prefix="secrets/../admin") # Should reject null bytes with pytest.raises(ValueError, match="invalid characters"): - connector.list_secrets(name_prefix="secrets\x00admin") + connector.list_secrets(prefix="secrets\x00admin") + + def test_list_secrets_does_not_preserve_name_prefix_alias(self, base_connector_kwargs): + """Clean major-version surface should keep prefix as the only prefix keyword.""" + connector = AWSConnector(**base_connector_kwargs) + + with pytest.raises(TypeError, match="name_prefix"): + connector.list_secrets(name_prefix="/vendors/") # type: ignore[call-arg] + + def test_get_secret_returns_extended_string(self, base_connector_kwargs): + """Ensure get_secret promotes returned secret strings.""" + connector = AWSConnector(**base_connector_kwargs) + mock_client = MagicMock() + mock_client.get_secret_value.return_value = {"SecretString": "secret-value"} + connector.get_aws_client = MagicMock(return_value=mock_client) + + value = connector.get_secret("arn:secret:test") + + assert isinstance(value, ExtendedString) + assert value == "secret-value" + + def test_get_secret_redacts_client_error_diagnostics(self, base_connector_kwargs): + """AWS secret lookup failures should not expose IDs or secret-bearing error text.""" + connector = AWSConnector(**base_connector_kwargs) + mock_client = MagicMock() + mock_client.get_secret_value.side_effect = ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "denied token=raw_token password=hunter2"}}, + "GetSecretValue", + ) + connector.get_aws_client = MagicMock(return_value=mock_client) + + with pytest.raises(ValueError) as exc_info: + connector.get_secret("prod/customer-private") + + diagnostics = _logged_text(connector.logger) + str(exc_info.value) + assert "prod/customer-private" not in diagnostics + assert "raw_token" not in diagnostics + assert "hunter2" not in diagnostics + assert "[REDACTED]" in diagnostics + assert exc_info.value.__cause__ is None + assert all("exc_info" not in logged_call.kwargs for logged_call in connector.logger.method_calls) + + def test_get_secret_redacts_missing_secret_log(self, base_connector_kwargs): + """AWS missing-secret logs should not expose raw requested IDs.""" + connector = AWSConnector(**base_connector_kwargs) + mock_client = MagicMock() + mock_client.get_secret_value.side_effect = ClientError( + {"Error": {"Code": "ResourceNotFoundException", "Message": "missing"}}, + "GetSecretValue", + ) + connector.get_aws_client = MagicMock(return_value=mock_client) + + assert connector.get_secret("prod/customer-private") is None + + logs = _logged_text(connector.logger) + assert "prod/customer-private" not in logs + assert "[REDACTED]" in logs def test_create_secret_with_tags_and_description(self, base_connector_kwargs): """Ensure create_secret builds payload and sends to AWS.""" @@ -240,6 +341,8 @@ def test_create_secret_with_tags_and_description(self, base_connector_kwargs): execution_role_arn="arn:role:override", ) + assert isinstance(response, ExtendedDict) + assert isinstance(response["ARN"], ExtendedString) assert response == {"ARN": "arn:secret:test"} connector.get_aws_client.assert_called_once_with( client_name="secretsmanager", @@ -261,6 +364,28 @@ def test_create_secret_requires_name(self, base_connector_kwargs): with pytest.raises(ValueError, match="name is required"): connector.create_secret(name="", secret_value="value") + def test_create_secret_redacts_error_diagnostics(self, base_connector_kwargs): + """AWS secret creation failures should not expose names, values, or exception secrets.""" + connector = AWSConnector(**base_connector_kwargs) + mock_client = MagicMock() + mock_client.create_secret.side_effect = ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "denied secret=raw-secret api_key=key_123"}}, + "CreateSecret", + ) + connector.get_aws_client = MagicMock(return_value=mock_client) + + with pytest.raises(RuntimeError) as exc_info: + connector.create_secret(name="/vendors/private", secret_value="super-secret") + + diagnostics = _logged_text(connector.logger) + str(exc_info.value) + assert "/vendors/private" not in diagnostics + assert "super-secret" not in diagnostics + assert "raw-secret" not in diagnostics + assert "key_123" not in diagnostics + assert "[REDACTED]" in diagnostics + assert exc_info.value.__cause__ is None + assert all("exc_info" not in logged_call.kwargs for logged_call in connector.logger.method_calls) + def test_update_secret_calls_aws(self, base_connector_kwargs): """Ensure update_secret forwards call to boto3 client.""" connector = AWSConnector(**base_connector_kwargs) @@ -274,6 +399,8 @@ def test_update_secret_calls_aws(self, base_connector_kwargs): execution_role_arn="arn:role:override", ) + assert isinstance(response, ExtendedDict) + assert isinstance(response["ARN"], ExtendedString) assert response == {"ARN": "arn:secret:test"} connector.get_aws_client.assert_called_once_with( client_name="secretsmanager", @@ -294,6 +421,8 @@ def test_delete_secret_with_recovery_window(self, base_connector_kwargs): execution_role_arn="arn:role:override", ) + assert isinstance(response, ExtendedDict) + assert isinstance(response["ARN"], ExtendedString) assert response == {"ARN": "arn:secret:test"} mock_client.delete_secret.assert_called_once_with(SecretId="arn:secret:test", RecoveryWindowInDays=10) @@ -325,16 +454,21 @@ def test_delete_secrets_matching_dry_run(self, base_connector_kwargs): connector.delete_secret = MagicMock() to_delete = connector.delete_secrets_matching( - name_prefix="/vendors/", + prefix="/vendors/", dry_run=True, force_delete=False, execution_role_arn="arn:role:override", ) + assert isinstance(to_delete, ExtendedList) + assert isinstance(to_delete[0], ExtendedString) assert to_delete == ["arn:a", "arn:b"] connector.delete_secret.assert_not_called() + logs = _logged_text(connector.logger) + assert "/vendors/" not in logs + assert "[REDACTED]" in logs connector.list_secrets.assert_called_once_with( - name_prefix="/vendors/", + prefix="/vendors/", execution_role_arn="arn:role:override", ) @@ -347,12 +481,14 @@ def test_delete_secrets_matching_executes_delete(self, base_connector_kwargs): ) deleted = connector.delete_secrets_matching( - name_prefix="/vendors/", + prefix="/vendors/", dry_run=False, force_delete=True, execution_role_arn="arn:role:override", ) + assert isinstance(deleted, ExtendedList) + assert isinstance(deleted[0], ExtendedString) assert deleted == ["arn:a", "arn:b"] connector.delete_secret.assert_has_calls( [ @@ -370,3 +506,68 @@ def test_delete_secrets_matching_executes_delete(self, base_connector_kwargs): ), ] ) + + def test_delete_secrets_matching_does_not_preserve_name_prefix_alias(self, base_connector_kwargs): + """Clean major-version surface should keep prefix as the only deletion keyword.""" + connector = AWSConnector(**base_connector_kwargs) + + with pytest.raises(TypeError, match="name_prefix"): + connector.delete_secrets_matching(name_prefix="/vendors/") # type: ignore[call-arg] + + def test_copy_secrets_to_s3_unwraps_extended_data(self, base_connector_kwargs): + """Ensure copy_secrets_to_s3 uploads JSON built from plain containers.""" + connector = AWSConnector(**base_connector_kwargs) + mock_client = MagicMock() + connector.get_aws_client = MagicMock(return_value=mock_client) + + uri = connector.copy_secrets_to_s3( + secrets=extend_data({"TOKEN": "secret-value"}), + bucket="target-bucket", + key="secrets.json", + ) + + assert isinstance(uri, ExtendedString) + assert uri == "s3://target-bucket/secrets.json" + logs = _logged_text(connector.logger) + assert "target-bucket" not in logs + assert "secrets.json" not in logs + mock_client.put_object.assert_called_once_with( + Bucket="target-bucket", + Key="secrets.json", + Body=b'{"TOKEN": "secret-value"}', + ContentType="application/json", + ) + + def test_load_secrets_by_prefix_returns_extended_mapping(self, base_connector_kwargs): + """Ensure prefix-loaded secrets are promoted without vendor-specific naming.""" + connector = AWSConnector(**base_connector_kwargs) + connector.list_secrets = MagicMock(return_value={"/services/github_token": "ghp_test"}) + + secrets = connector.load_secrets_by_prefix( + prefix="/services/", + uppercase_keys=True, + execution_role_arn="arn:role:override", + role_session_name="session", + ) + + assert isinstance(secrets, ExtendedDict) + assert isinstance(secrets["GITHUB_TOKEN"], ExtendedString) + assert secrets == {"GITHUB_TOKEN": "ghp_test"} + connector.list_secrets.assert_called_once_with( + prefix="/services/", + get_secret_values=True, + skip_empty_secrets=True, + execution_role_arn="arn:role:override", + role_session_name="session", + ) + + def test_load_secrets_by_prefix_requires_prefix(self, base_connector_kwargs): + """Ensure prefix loading fails loudly without a prefix.""" + connector = AWSConnector(**base_connector_kwargs) + + with pytest.raises(ValueError, match="prefix is required"): + connector.load_secrets_by_prefix("") + + def test_aws_connector_does_not_keep_vendor_secret_loader_alias(self): + """Clean major-version surface should not preserve the old vendor loader name.""" + assert not hasattr(AWSConnector, "load_vendors_from_asm") diff --git a/tests/connectors/test_aws_organizations.py b/tests/connectors/test_aws_organizations.py index ea4f075..7167038 100644 --- a/tests/connectors/test_aws_organizations.py +++ b/tests/connectors/test_aws_organizations.py @@ -1,11 +1,20 @@ +# ruff: noqa: I001 """Tests for AWS Organizations helper mixin.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any +from unittest.mock import MagicMock import pytest +pytest.importorskip("boto3") +pytest.importorskip("botocore") + +from botocore.exceptions import ClientError + +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString, extend_data from extended_data.connectors.aws.organizations import AWSOrganizationsMixin @@ -31,6 +40,52 @@ def list_roots(self): return {"Roots": [{"Id": "r-root"}]} +class _ParentPaginator: + def __init__(self, pages_by_parent: Mapping[str, list[dict[str, Any]]]) -> None: + self.pages_by_parent = pages_by_parent + + def paginate(self, ParentId: str) -> list[dict[str, Any]]: + return self.pages_by_parent.get(ParentId, []) + + +class _ResourceTagPaginator: + def __init__(self, tags_by_resource: Mapping[str, list[dict[str, str]]]) -> None: + self.tags_by_resource = tags_by_resource + + def paginate(self, ResourceId: str) -> list[dict[str, Any]]: + return [{"Tags": self.tags_by_resource.get(ResourceId, [])}] + + +class _OrganizationTreeClient: + def __init__( + self, + *, + account_pages: Mapping[str, list[dict[str, Any]]] | None = None, + ou_pages: Mapping[str, list[dict[str, Any]]] | None = None, + tags_by_resource: Mapping[str, list[dict[str, str]]] | None = None, + ) -> None: + self.account_pages = account_pages or {} + self.ou_pages = ou_pages or {} + self.tags_by_resource = tags_by_resource or {} + + def list_roots(self): + return {"Roots": [{"Id": "r-root"}]} + + def get_paginator(self, name: str): + if name == "list_accounts_for_parent": + return _ParentPaginator(self.account_pages) + if name == "list_organizational_units_for_parent": + return _ParentPaginator(self.ou_pages) + if name == "list_tags_for_resource": + return _ResourceTagPaginator(self.tags_by_resource) + raise AssertionError(f"unexpected paginator {name}") + + +def _logged_text(logger: MagicMock) -> str: + """Return concatenated mock logger messages.""" + return "\n".join(str(arg) for call in logger.method_calls for arg in call.args) + + class _TestAWSOrganizations(AWSOrganizationsMixin): def __init__(self) -> None: self.logger = _StubLogger() @@ -43,6 +98,9 @@ def register_client(self, name: str, client: Any) -> None: def get_aws_client(self, client_name: str, execution_role_arn=None): return self._clients[client_name] + def extend_result(self, value: Any) -> Any: + return extend_data(value) + @pytest.fixture def organizations_connector() -> _TestAWSOrganizations: @@ -67,6 +125,9 @@ def test_classify_accounts_applies_rules(organizations_connector: _TestAWSOrgani }, ) + assert isinstance(result, ExtendedDict) + assert isinstance(result["111111111111"], ExtendedDict) + assert isinstance(result["111111111111"]["classification"], ExtendedString) assert result["111111111111"]["classification"] == "production" assert result["222222222222"]["classification"] == "development" assert result["333333333333"]["classification"] == "sandbox" @@ -79,11 +140,13 @@ def test_classify_accounts_fetches_when_missing(mocker, organizations_connector: output = organizations_connector.classify_accounts() mock_get.assert_called_once() + assert isinstance(output, ExtendedDict) assert output["999999999999"]["classification"] == "shared" def test_label_account_tags_resource(organizations_connector: _TestAWSOrganizations): client = organizations_connector._clients["organizations"] + organizations_connector.logger = MagicMock() organizations_connector.label_account("123456789012", {"Env": "prod", "Owner": "platform"}) @@ -96,6 +159,170 @@ def test_label_account_tags_resource(organizations_connector: _TestAWSOrganizati ], } ] + logs = _logged_text(organizations_connector.logger) + assert "123456789012" not in logs + assert "[REDACTED]" in logs + + +def test_get_organization_accounts_redacts_root_parent_id() -> None: + class _Paginator: + def __init__(self, pages: list[dict[str, Any]]) -> None: + self.pages = pages + + def paginate(self, **_: Any) -> list[dict[str, Any]]: + return self.pages + + class _RootClient: + def list_roots(self): + return {"Roots": [{"Id": "r-sensitive-root"}]} + + def get_paginator(self, name: str): + if name == "list_accounts_for_parent": + return _Paginator([{"Accounts": []}]) + if name == "list_organizational_units_for_parent": + return _Paginator([{"OrganizationalUnits": []}]) + return _Paginator([]) + + connector = _TestAWSOrganizations() + connector.logger = MagicMock() + connector.register_client("organizations", _RootClient()) + + assert connector.get_organization_accounts() == {} + + logs = _logged_text(connector.logger) + assert "r-sensitive-root" not in logs + assert "[REDACTED]" in logs + + +def test_get_organization_accounts_redacts_missing_root_payload() -> None: + class _BadRootClient: + def list_roots(self): + return {"Roots": [{"AccountId": "123456789012"}]} + + connector = _TestAWSOrganizations() + connector.logger = MagicMock() + connector.register_client("organizations", _BadRootClient()) + + with pytest.raises(RuntimeError) as exc_info: + connector.get_organization_accounts() + + assert "123456789012" not in str(exc_info.value) + assert "[REDACTED]" in str(exc_info.value) + assert exc_info.value.__cause__ is None + + +def test_get_organization_accounts_recurses_units_tags_and_sorts() -> None: + """Organization account discovery should merge OU metadata, tags, and sort promoted payloads.""" + client = _OrganizationTreeClient( + account_pages={ + "r-root": [ + { + "Accounts": [ + { + "Id": "222222222222", + "Name": "Beta", + "Email": "beta@example.com", + "Status": "ACTIVE", + } + ] + } + ], + "ou-prod": [ + { + "Accounts": [ + { + "Id": "111111111111", + "Name": "Alpha", + "Email": "alpha@example.com", + "Status": "ACTIVE", + } + ] + } + ], + }, + ou_pages={ + "r-root": [ + { + "OrganizationalUnits": [ + {"Id": "ou-prod", "Arn": "arn:aws:organizations::123:ou/o-root/ou-prod", "Name": "Prod"} + ] + } + ], + "ou-prod": [{"OrganizationalUnits": []}], + }, + tags_by_resource={ + "111111111111": [{"Key": "Environment", "Value": "prod"}], + "222222222222": [{"Key": "Owner", "Value": "platform"}], + }, + ) + connector = _TestAWSOrganizations() + connector.register_client("organizations", client) + + result = connector.get_organization_accounts(unhump_accounts=True, sort_by_name=True) + + assert isinstance(result, ExtendedDict) + assert list(result.keys()) == ["111111111111", "222222222222"] + assert result["111111111111"]["ou_name"] == "Prod" + assert result["111111111111"]["tags"]["environment"] == "prod" + assert result["111111111111"]["managed"] is False + assert result["222222222222"]["tags"]["owner"] == "platform" + + +def test_get_controltower_accounts_redacts_provider_warning() -> None: + class _ControlTowerClient: + def get_paginator(self, _: str): + raise ClientError( + {"Error": {"Code": "AccessDenied", "Message": "Denied for 123456789012 token=raw-token"}}, + "SearchProvisionedProducts", + ) + + connector = _TestAWSOrganizations() + connector.logger = MagicMock() + connector.register_client("servicecatalog", _ControlTowerClient()) + + assert connector.get_controltower_accounts() == {} + + logs = _logged_text(connector.logger) + assert "123456789012" not in logs + assert "raw-token" not in logs + assert "[REDACTED]" in logs + + +def test_get_controltower_accounts_extracts_outputs_and_skips_failed_products() -> None: + """Control Tower discovery should map AccountId outputs and skip unreadable products.""" + + class _ProvisionedProductsPaginator: + def paginate(self, **_: Any) -> list[dict[str, Any]]: + return [ + { + "ProvisionedProducts": [ + {"Id": "pp-good", "Name": "Managed Alpha", "Status": "AVAILABLE"}, + {"Id": "pp-denied", "Name": "Denied", "Status": "TAINTED"}, + {"Name": "No Id", "Status": "AVAILABLE"}, + ] + } + ] + + class _ControlTowerClient: + def get_paginator(self, name: str): + assert name == "search_provisioned_products" + return _ProvisionedProductsPaginator() + + def get_provisioned_product_outputs(self, ProvisionedProductId: str): + if ProvisionedProductId == "pp-denied": + raise ClientError({"Error": {"Code": "Denied", "Message": "private account 123456789012"}}, "Outputs") + return {"Outputs": [{"OutputKey": "AccountId", "OutputValue": "111111111111"}]} + + connector = _TestAWSOrganizations() + connector.register_client("servicecatalog", _ControlTowerClient()) + + result = connector.get_controltower_accounts(unhump_accounts=True, sort_by_name=True) + + assert isinstance(result, ExtendedDict) + assert list(result.keys()) == ["111111111111"] + assert result["111111111111"]["name"] == "Managed Alpha" + assert result["111111111111"]["managed"] is True + assert result["111111111111"]["provisioned_product_id"] == "pp-good" def test_preprocess_organization_compiles_sections(mocker, organizations_connector: _TestAWSOrganizations): @@ -121,6 +348,8 @@ def test_preprocess_organization_compiles_sections(mocker, organizations_connect mock_classify.assert_called_once() mock_get_units.assert_called_once() + assert isinstance(result, ExtendedDict) + assert isinstance(result["accounts"], ExtendedDict) assert result["root_id"] == "r-root" assert result["account_count"] == 1 assert result["ou_count"] == 1 @@ -128,6 +357,24 @@ def test_preprocess_organization_compiles_sections(mocker, organizations_connect assert result["organizational_units"] == {"ou-1": {"name": "Shared"}} +def test_preprocess_organization_can_skip_classification(mocker, organizations_connector: _TestAWSOrganizations): + """Legacy preprocess helper should be able to emit raw account metadata.""" + mock_get_accounts = mocker.patch.object( + organizations_connector, + "get_accounts", + return_value={"123": {"name": "core"}}, + ) + mock_classify = mocker.patch.object(organizations_connector, "classify_accounts") + mocker.patch.object(organizations_connector, "get_organization_units", return_value={}) + + result = organizations_connector.preprocess_organization(include_classification=False) + + mock_get_accounts.assert_called_once() + mock_classify.assert_not_called() + assert result["accounts"] == {"123": {"name": "core"}} + assert result["account_count"] == 1 + + def test_get_accounts_merges_controltower_data(mocker, organizations_connector: _TestAWSOrganizations): mock_org = mocker.patch.object( organizations_connector, @@ -151,11 +398,92 @@ def test_get_accounts_merges_controltower_data(mocker, organizations_connector: mock_org.assert_called_once() mock_ctrl.assert_called_once() + assert isinstance(result, ExtendedDict) + assert isinstance(result["100"], ExtendedDict) + assert isinstance(result["100"]["name"], ExtendedString) assert list(result.keys()) == ["100", "200", "300"] assert result["200"]["managed"] is True assert result["100"]["name"] == "Alpha" +def test_get_accounts_can_skip_controltower_merge(mocker, organizations_connector: _TestAWSOrganizations): + """Combined account discovery should support Organizations-only callers.""" + mocker.patch.object( + organizations_connector, + "get_organization_accounts", + return_value={"200": {"Name": "Beta", "managed": False}}, + ) + controltower = mocker.patch.object(organizations_connector, "get_controltower_accounts", return_value={}) + + result = organizations_connector.get_accounts(include_controltower=False, unhump_accounts=False) + + controltower.assert_not_called() + assert result["200"]["Name"] == "Beta" + assert result["200"]["managed"] is False + + +def test_get_organization_units_builds_recursive_paths() -> None: + """Organizational unit discovery should preserve recursive OU paths.""" + client = _OrganizationTreeClient( + ou_pages={ + "r-root": [ + { + "OrganizationalUnits": [ + {"Id": "ou-prod", "Arn": "arn:aws:organizations::123:ou/o-root/ou-prod", "Name": "Prod"} + ] + } + ], + "ou-prod": [ + { + "OrganizationalUnits": [ + {"Id": "ou-apps", "Arn": "arn:aws:organizations::123:ou/o-root/ou-apps", "Name": "Apps"} + ] + } + ], + "ou-apps": [{"OrganizationalUnits": []}], + } + ) + connector = _TestAWSOrganizations() + connector.register_client("organizations", client) + + result = connector.get_organization_units(unhump_units=True) + + assert isinstance(result, ExtendedDict) + assert result["ou-prod"]["path"] == "Prod" + assert result["ou-apps"]["path"] == "Prod/Apps" + + +def test_build_org_units_with_tags_collects_control_tower_labels() -> None: + """Tagged OU helper should return normalized metadata used by account labeling.""" + client = _OrganizationTreeClient( + ou_pages={ + "r-root": [ + { + "OrganizationalUnits": [ + {"Id": "ou-prod", "Arn": "arn:aws:organizations::123:ou/o-root/ou-prod", "Name": "Prod"} + ] + } + ], + "ou-prod": [{"OrganizationalUnits": []}], + }, + tags_by_resource={"ou-prod": [{"Key": "Environment", "Value": "prod"}]}, + ) + connector = _TestAWSOrganizations() + connector.register_client("organizations", client) + + result = connector._build_org_units_with_tags(role_arn=None) + + assert result == { + "ou-prod": { + "id": "ou-prod", + "name": "Prod", + "arn": "arn:aws:organizations::123:ou/o-root/ou-prod", + "tags": {"Environment": "prod"}, + "control_tower_organizational_unit": "Prod (ou-prod)", + } + } + + def test_label_aws_accounts_builds_metadata(mocker, organizations_connector: _TestAWSOrganizations): mocker.patch.object( organizations_connector, @@ -187,6 +515,9 @@ def test_label_aws_accounts_builds_metadata(mocker, organizations_connector: _Te labeled = organizations_connector.label_aws_accounts(domains={"prod": "example.com"}) account = labeled["123456789012"] + assert isinstance(labeled, ExtendedDict) + assert isinstance(account, ExtendedDict) + assert isinstance(account["execution_role_arn"], ExtendedString) assert account["json_key"] == "ProdAccount" assert account["execution_role_arn"].endswith("role/CustomRole") assert account["environment"] == "prod" @@ -194,6 +525,69 @@ def test_label_aws_accounts_builds_metadata(mocker, organizations_connector: _Te assert ".example.com" in account["subdomain"] +def test_build_labeled_account_handles_root_user_defaults_and_unit_name_lookup( + organizations_connector: _TestAWSOrganizations, +): + """Account labeling should cover root account and OU-name lookup defaults.""" + labeled = organizations_connector._build_labeled_account( + account_id="123456789012", + account_data={ + "Name": "User-Sandbox", + "Email": "user@example.com", + "OuName": "Sandbox", + "tags": {"Classifications": "Sandbox Accounts"}, + }, + controltower_data=None, + units_lookup={ + "ou-sandbox": { + "id": "ou-sandbox", + "name": "Sandbox", + "tags": {"Spoke": "true", "Classifications": "Development Accounts"}, + } + }, + domains={"dev": "dev.example.com", "default": "example.com"}, + caller_account_id="123456789012", + ) + + assert labeled["execution_role_arn"] == "" + assert labeled["environment"] == "dev" + assert labeled["domain"] == "dev.example.com" + assert labeled["subdomain"] == "dev.example.com" + assert labeled["spoke"] is True + assert set(labeled["classifications"]) == {"accounts", "sandbox", "development"} + + +def test_label_aws_accounts_includes_controltower_only_accounts(mocker, organizations_connector: _TestAWSOrganizations): + """Control Tower-only accounts should still receive normalized account labels.""" + mocker.patch.object(organizations_connector, "get_organization_accounts", return_value={}) + mocker.patch.object( + organizations_connector, + "get_controltower_accounts", + return_value={ + "999999999999": { + "Name": "Managed Shared", + "Email": "shared@example.com", + "managed": True, + "OrganizationalUnit": "Shared", + "ProvisionedProductId": "pp-999", + "tags": {"Environment": "stg"}, + } + }, + ) + organizations_connector.get_caller_account_id = lambda: "000000000000" # type: ignore[assignment] + + result = organizations_connector.label_aws_accounts( + domains={"stg": "example.com"}, + aws_organization_units={"ou-shared": {"id": "ou-shared", "name": "Shared", "tags": {}}}, + ) + + account = result["999999999999"] + assert account["managed"] is True + assert account["provisioned_product_id"] == "pp-999" + assert account["organizational_unit"] == "Shared" + assert account["subdomain"] == "managedshared.example.com" + + def test_classify_aws_accounts_generates_suffix(organizations_connector: _TestAWSOrganizations): labeled = { "123": {"classifications": ["production", "shared"]}, @@ -202,10 +596,41 @@ def test_classify_aws_accounts_generates_suffix(organizations_connector: _TestAW result = organizations_connector.classify_aws_accounts(labeled_accounts=labeled, suffix="_east") + assert isinstance(result, ExtendedDict) + assert isinstance(result["production_accounts_east"], ExtendedList) + assert isinstance(result["production_accounts_east"][0], ExtendedString) assert result["production_accounts_east"] == ["123"] assert result["development_accounts_east"] == ["456"] +def test_classify_aws_accounts_fetches_labels_when_domains_are_provided( + mocker, + organizations_connector: _TestAWSOrganizations, +): + """Classification grouping should build labels when callers provide source domains.""" + label = mocker.patch.object( + organizations_connector, + "label_aws_accounts", + return_value={ + "123": {"classifications": ["production", "accounts"]}, + "456": {"classifications": ["shared"]}, + }, + ) + + result = organizations_connector.classify_aws_accounts(domains={"prod": "example.com"}) + + label.assert_called_once() + assert result == {"production_accounts": ["123"], "shared_accounts": ["456"]} + + +def test_classify_aws_accounts_requires_domains_when_labels_are_missing( + organizations_connector: _TestAWSOrganizations, +): + """Classification grouping should fail loudly without enough source data.""" + with pytest.raises(ValueError, match="domains mapping required"): + organizations_connector.classify_aws_accounts() + + def test_preprocess_aws_organization_uses_helpers(mocker, organizations_connector: _TestAWSOrganizations): labeled_accounts = { "123": { @@ -243,6 +668,36 @@ def list_roots(self): context = organizations_connector.preprocess_aws_organization(domains={"prod": "example.com"}) + assert isinstance(context, ExtendedDict) + assert isinstance(context["accounts_by_name"], ExtendedDict) + assert isinstance(context["organization"], ExtendedDict) assert context["organization"]["root_id"] == "r-root" assert context["accounts_by_name"]["Prod Account"]["email"] == "prod@example.com" assert context["accounts_by_classification"]["production_accounts"] == ["123"] + + +def test_preprocess_aws_organization_accepts_precomputed_units(mocker, organizations_connector: _TestAWSOrganizations): + """Full organization preprocessing should reuse caller-provided OU metadata.""" + build_units = mocker.patch.object(organizations_connector, "_build_org_units_with_tags") + mocker.patch.object( + organizations_connector, + "label_aws_accounts", + return_value={ + "123": { + "account_name": "Shared", + "email": "shared@example.com", + "json_key": "Shared", + "classifications": ["shared"], + } + }, + ) + mocker.patch.object(organizations_connector, "classify_aws_accounts", return_value={"shared_accounts": ["123"]}) + + context = organizations_connector.preprocess_aws_organization( + domains={"default": "example.com"}, + aws_organization_units={"ou-shared": {"id": "ou-shared", "name": "Shared", "classifications": ["shared"]}}, + ) + + build_units.assert_not_called() + assert context["organization"]["ou_count"] == 1 + assert context["unit_classifications_by_name"]["Shared"] == ["shared"] diff --git a/tests/connectors/test_aws_s3.py b/tests/connectors/test_aws_s3.py index be8d976..1cdee5b 100644 --- a/tests/connectors/test_aws_s3.py +++ b/tests/connectors/test_aws_s3.py @@ -1,3 +1,4 @@ +# ruff: noqa: I001 """Tests for AWS S3 operations.""" from __future__ import annotations @@ -9,16 +10,26 @@ import pytest +pytest.importorskip("boto3") +pytest.importorskip("botocore") + from botocore.exceptions import ClientError -from extended_data.connectors.aws import AWSConnectorFull +from extended_data.connectors.aws import AWSConnector +from extended_data.connectors.aws import s3 as s3_module +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString, extend_data + + +def _logged_text(logger: MagicMock) -> str: + """Return concatenated mock logger messages.""" + return "\n".join(str(arg) for call in logger.method_calls for arg in call.args) @pytest.fixture def aws_connector(): """Create AWS connector with mocked clients.""" with patch("extended_data.connectors.aws.boto3"): - connector = AWSConnectorFull() + connector = AWSConnector() connector.logger = MagicMock() return connector @@ -39,6 +50,8 @@ def test_list_s3_buckets(self, aws_connector): result = aws_connector.list_s3_buckets(unhump_buckets=False) + assert isinstance(result, ExtendedDict) + assert isinstance(result["bucket1"], ExtendedDict) assert len(result) == 2 assert "bucket1" in result assert "bucket2" in result @@ -56,7 +69,7 @@ def test_list_s3_buckets_with_unhump(self, aws_connector): assert "bucket1" in result # unhump_map transforms CamelCase keys to snake_case # If unhump was applied, we should have snake_case keys - # The actual transformation happens in extended_data.unhump_map + # The actual transformation happens in extended_data.primitives.unhump_map def test_get_bucket_location(self, aws_connector): """Test getting bucket location.""" @@ -66,6 +79,7 @@ def test_get_bucket_location(self, aws_connector): result = aws_connector.get_bucket_location("my-bucket") + assert isinstance(result, ExtendedString) assert result == "us-west-2" mock_s3.get_bucket_location.assert_called_once_with(Bucket="my-bucket") @@ -77,6 +91,7 @@ def test_get_bucket_location_us_east_1(self, aws_connector): result = aws_connector.get_bucket_location("my-bucket") + assert isinstance(result, ExtendedString) assert result == "us-east-1" def test_get_bucket_tags(self, aws_connector): @@ -92,6 +107,8 @@ def test_get_bucket_tags(self, aws_connector): result = aws_connector.get_bucket_tags("my-bucket") + assert isinstance(result, ExtendedDict) + assert isinstance(result["Environment"], ExtendedString) assert result == {"Environment": "dev", "Owner": "team"} def test_get_bucket_tags_no_tags(self, aws_connector): @@ -103,6 +120,7 @@ def test_get_bucket_tags_no_tags(self, aws_connector): result = aws_connector.get_bucket_tags("my-bucket") + assert isinstance(result, ExtendedDict) assert result == {} def test_get_bucket_tags_other_error(self, aws_connector): @@ -144,6 +162,7 @@ def test_get_object_success(self, aws_connector): result = aws_connector.get_object("bucket", "key.txt", decode=True) + assert isinstance(result, ExtendedString) assert result == "test content" mock_s3.get_object.assert_called_once_with(Bucket="bucket", Key="key.txt") @@ -170,6 +189,22 @@ def test_get_object_not_found(self, aws_connector): assert result is None + def test_get_object_not_found_logs_redact_bucket_and_key(self, aws_connector): + """Missing object diagnostics should not expose S3 resource identifiers.""" + mock_s3 = MagicMock() + error = ClientError({"Error": {"Code": "NoSuchKey"}}, "GetObject") + mock_s3.get_object.side_effect = error + aws_connector.get_aws_client = MagicMock(return_value=mock_s3) + + result = aws_connector.get_object("prod-secrets-bucket", "customers/acme/private.json") + + assert result is None + mock_s3.get_object.assert_called_once_with(Bucket="prod-secrets-bucket", Key="customers/acme/private.json") + logs = _logged_text(aws_connector.logger) + assert "[REDACTED]" in logs + assert "prod-secrets-bucket" not in logs + assert "customers/acme/private.json" not in logs + def test_get_object_other_error(self, aws_connector): """Test getting an object with other error.""" mock_s3 = MagicMock() @@ -191,8 +226,27 @@ def test_get_json_object(self, aws_connector): result = aws_connector.get_json_object("bucket", "data.json") + assert isinstance(result, ExtendedDict) + assert isinstance(result["key"], ExtendedString) assert result == test_data + def test_get_json_object_decodes_through_data_boundary(self, aws_connector): + """S3 JSON reads should use the shared file decoder, not local json.loads.""" + mock_s3 = MagicMock() + mock_body = MagicMock() + mock_body.read.return_value = b'{"items":[{"name":"one"}]}' + mock_s3.get_object.return_value = {"Body": mock_body} + aws_connector.get_aws_client = MagicMock(return_value=mock_s3) + + with patch("extended_data.connectors.aws.s3.decode_file", wraps=s3_module.decode_file) as mock_decode_file: + result = aws_connector.get_json_object("bucket", "data.json") + + assert isinstance(result, ExtendedDict) + assert isinstance(result["items"], ExtendedList) + assert isinstance(result["items"][0], ExtendedDict) + assert isinstance(result["items"][0]["name"], ExtendedString) + mock_decode_file.assert_called_once_with(b'{"items":[{"name":"one"}]}', suffix="json", as_extended=True) + def test_get_json_object_not_found(self, aws_connector): """Test getting a non-existent JSON object.""" mock_s3 = MagicMock() @@ -212,6 +266,8 @@ def test_put_object_string(self, aws_connector): result = aws_connector.put_object("bucket", "key.txt", "test content") + assert isinstance(result, ExtendedDict) + assert isinstance(result["ETag"], ExtendedString) assert result["ETag"] == "abc123" call_args = mock_s3.put_object.call_args[1] assert call_args["Bucket"] == "bucket" @@ -276,15 +332,22 @@ def test_put_json_object(self, aws_connector): mock_s3.put_object.return_value = {"ETag": "abc123"} aws_connector.get_aws_client = MagicMock(return_value=mock_s3) - data = {"key": "value", "number": 123} - result = aws_connector.put_json_object("bucket", "data.json", data) + data = extend_data({"key": "value", "number": 123}) + with patch( + "extended_data.connectors.aws.s3.wrap_raw_data_for_export", + wraps=s3_module.wrap_raw_data_for_export, + ) as mock_wrap_for_export: + result = aws_connector.put_json_object("bucket", "data.json", data) + assert isinstance(result, ExtendedDict) + assert isinstance(result["ETag"], ExtendedString) assert result["ETag"] == "abc123" call_args = mock_s3.put_object.call_args[1] assert call_args["ContentType"] == "application/json" # Verify JSON was serialized body_str = call_args["Body"].decode("utf-8") assert json.loads(body_str) == data + mock_wrap_for_export.assert_called_once_with(data, allow_encoding="json", indent_2=True) def test_delete_object(self, aws_connector): """Test deleting an object.""" @@ -294,6 +357,7 @@ def test_delete_object(self, aws_connector): result = aws_connector.delete_object("bucket", "key.txt") + assert isinstance(result, ExtendedDict) assert result["DeleteMarker"] is True mock_s3.delete_object.assert_called_once_with(Bucket="bucket", Key="key.txt") @@ -315,6 +379,9 @@ def test_list_objects(self, aws_connector): result = aws_connector.list_objects("bucket", unhump_objects=False) + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) + assert isinstance(result[0]["Key"], ExtendedString) assert len(result) == 3 assert result[0]["Key"] == "file1.txt" assert result[2]["Key"] == "file3.txt" @@ -353,6 +420,7 @@ def test_copy_object(self, aws_connector): result = aws_connector.copy_object("src-bucket", "src.txt", "dst-bucket", "dst.txt") + assert isinstance(result, ExtendedDict) assert "CopyObjectResult" in result mock_s3.copy_object.assert_called_once_with( Bucket="dst-bucket", @@ -395,6 +463,9 @@ def test_get_bucket_features(self, aws_connector): result = aws_connector.get_bucket_features("my-bucket") + assert isinstance(result, ExtendedDict) + assert isinstance(result["logging"], ExtendedDict) + assert isinstance(result["lifecycle_rules"], ExtendedList) assert result["logging"] == {"TargetBucket": "logs"} assert result["versioning"] == "Enabled" assert result["lifecycle_rules"] == [{"Id": "rule1"}] @@ -411,6 +482,7 @@ def test_get_bucket_features_no_bucket(self, aws_connector): result = aws_connector.get_bucket_features("missing-bucket") + assert isinstance(result, ExtendedDict) assert result == {} def test_get_bucket_features_errors(self, aws_connector): @@ -456,6 +528,8 @@ def test_find_buckets_by_name(self, aws_connector): result = aws_connector.find_buckets_by_name("app") + assert isinstance(result, ExtendedDict) + assert isinstance(result["prod-app-bucket"], ExtendedDict) assert len(result) == 2 assert "prod-app-bucket" in result assert "dev-app-bucket" in result @@ -469,11 +543,26 @@ def test_create_bucket_simple(self, aws_connector): result = aws_connector.create_bucket("my-bucket") + assert isinstance(result, ExtendedDict) + assert isinstance(result["Location"], ExtendedString) assert result["Location"] == "/my-bucket" call_args = mock_s3.create_bucket.call_args[1] assert call_args["Bucket"] == "my-bucket" assert call_args["ACL"] == "private" + def test_create_bucket_logs_redact_bucket_name_but_preserve_call_args(self, aws_connector): + """Bucket creation logs should redact resource names without changing API args.""" + mock_s3 = MagicMock() + mock_s3.create_bucket.return_value = {"Location": "/prod-secrets-bucket"} + aws_connector.get_aws_client = MagicMock(return_value=mock_s3) + + aws_connector.create_bucket("prod-secrets-bucket") + + assert mock_s3.create_bucket.call_args.kwargs["Bucket"] == "prod-secrets-bucket" + logs = _logged_text(aws_connector.logger) + assert "[REDACTED]" in logs + assert "prod-secrets-bucket" not in logs + def test_create_bucket_with_region(self, aws_connector): """Test creating bucket in specific region.""" mock_s3 = MagicMock() @@ -575,7 +664,23 @@ def get_client(client_name, **kwargs): result = aws_connector.get_bucket_sizes(bucket_names=["test-bucket"]) + assert isinstance(result, ExtendedDict) + assert isinstance(result["test-bucket"], ExtendedDict) assert "test-bucket" in result assert result["test-bucket"]["size_bytes"] == 1073741824 assert result["test-bucket"]["size_gb"] == 1.0 assert result["test-bucket"]["object_count"] == 100 + + def test_get_bucket_sizes_error_logs_redact_bucket_name(self, aws_connector): + """CloudWatch metric diagnostics should not leak bucket names.""" + mock_cloudwatch = MagicMock() + mock_cloudwatch.get_metric_statistics.side_effect = RuntimeError("denied for prod-secrets-bucket") + aws_connector.get_aws_client = MagicMock(return_value=mock_cloudwatch) + + result = aws_connector.get_bucket_sizes(bucket_names=["prod-secrets-bucket"]) + + assert result["prod-secrets-bucket"]["size_bytes"] == 0 + assert result["prod-secrets-bucket"]["object_count"] == 0 + logs = _logged_text(aws_connector.logger) + assert "[REDACTED]" in logs + assert "prod-secrets-bucket" not in logs diff --git a/tests/connectors/test_aws_sso.py b/tests/connectors/test_aws_sso.py index b5deac6..9708b57 100644 --- a/tests/connectors/test_aws_sso.py +++ b/tests/connectors/test_aws_sso.py @@ -1,3 +1,4 @@ +# ruff: noqa: I001 """Tests for AWS SSO/Identity Center operations.""" from __future__ import annotations @@ -6,16 +7,25 @@ import pytest +pytest.importorskip("boto3") +pytest.importorskip("botocore") + from botocore.exceptions import ClientError -from extended_data.connectors.aws import AWSConnectorFull +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString +from extended_data.connectors.aws import AWSConnector + + +def _logged_text(logger: MagicMock) -> str: + """Return concatenated mock logger messages.""" + return "\n".join(str(arg) for call in logger.method_calls for arg in call.args) @pytest.fixture def aws_connector(): """Create AWS connector with mocked clients.""" with patch("extended_data.connectors.aws.boto3"): - connector = AWSConnectorFull() + connector = AWSConnector() connector.logger = MagicMock() return connector @@ -38,8 +48,12 @@ def test_get_identity_store_id(self, aws_connector): result = aws_connector.get_identity_store_id() + assert isinstance(result, ExtendedString) assert result == "d-1234567890" aws_connector.get_aws_client.assert_called_once_with(client_name="sso-admin", execution_role_arn=None) + logs = _logged_text(aws_connector.logger) + assert "[REDACTED]" in logs + assert "d-1234567890" not in logs def test_get_identity_store_id_no_instance(self, aws_connector): """Test getting identity store ID with no instances.""" @@ -65,7 +79,11 @@ def test_get_sso_instance_arn(self, aws_connector): result = aws_connector.get_sso_instance_arn() + assert isinstance(result, ExtendedString) assert result == "arn:aws:sso:::instance/ssoins-1234567890" + logs = _logged_text(aws_connector.logger) + assert "[REDACTED]" in logs + assert "arn:aws:sso:::instance/ssoins-1234567890" not in logs def test_get_sso_instance_arn_no_instance(self, aws_connector): """Test getting SSO instance ARN with no instances.""" @@ -109,6 +127,9 @@ def get_client(client_name, **kwargs): result = aws_connector.list_sso_users(unhump_users=False, flatten_name=False) + assert isinstance(result, ExtendedDict) + assert isinstance(result["user-1"], ExtendedDict) + assert isinstance(result["user-1"]["UserName"], ExtendedString) assert len(result) == 2 assert "user-1" in result assert "user-2" in result @@ -138,6 +159,8 @@ def get_client(client_name, **kwargs): result = aws_connector.list_sso_users(unhump_users=False, flatten_name=True, identity_store_id="d-1234567890") + assert isinstance(result, ExtendedDict) + assert isinstance(result["user-1"], ExtendedDict) assert len(result) == 1 assert result["user-1"]["GivenName"] == "John" assert result["user-1"]["FamilyName"] == "Doe" @@ -196,6 +219,8 @@ def test_get_sso_user(self, aws_connector): result = aws_connector.get_sso_user("user-1", identity_store_id="d-1234567890") + assert isinstance(result, ExtendedDict) + assert isinstance(result["UserName"], ExtendedString) assert result["UserId"] == "user-1" assert result["UserName"] == "john.doe" @@ -255,6 +280,9 @@ def get_client(client_name, **kwargs): result = aws_connector.list_sso_groups(unhump_groups=False) + assert isinstance(result, ExtendedDict) + assert isinstance(result["group-1"], ExtendedDict) + assert isinstance(result["group-1"]["DisplayName"], ExtendedString) assert len(result) == 2 assert "group-1" in result assert result["group-1"]["DisplayName"] == "Admins" @@ -278,9 +306,39 @@ def get_client(client_name, **kwargs): result = aws_connector.create_sso_group("Admins", description="Admin group") + assert isinstance(result, ExtendedDict) + assert isinstance(result["GroupId"], ExtendedString) assert result["GroupId"] == "group-1" mock_identitystore.create_group.assert_called_once() + def test_create_sso_group_logs_redact_identifiers_but_preserve_call_args(self, aws_connector): + """Group mutation diagnostics should redact names and IDs.""" + mock_identitystore = MagicMock() + mock_identitystore.create_group.return_value = { + "GroupId": "group-sensitive-1", + "IdentityStoreId": "d-sensitive", + } + + def get_client(client_name, **kwargs): + if client_name == "identitystore": + return mock_identitystore + mock_sso_admin = MagicMock() + mock_sso_admin.list_instances.return_value = {"Instances": [{"IdentityStoreId": "d-sensitive"}]} + return mock_sso_admin + + aws_connector.get_aws_client = MagicMock(side_effect=get_client) + + aws_connector.create_sso_group("Executive Audit", description="Admin group") + + call_args = mock_identitystore.create_group.call_args.kwargs + assert call_args["DisplayName"] == "Executive Audit" + assert call_args["IdentityStoreId"] == "d-sensitive" + logs = _logged_text(aws_connector.logger) + assert "[REDACTED]" in logs + assert "Executive Audit" not in logs + assert "group-sensitive-1" not in logs + assert "d-sensitive" not in logs + def test_delete_sso_group(self, aws_connector): """Test deleting an SSO group.""" mock_identitystore = MagicMock() @@ -336,8 +394,11 @@ def test_list_permission_sets(self, aws_connector): result = aws_connector.list_permission_sets(unhump_sets=False) + assert isinstance(result, ExtendedDict) assert len(result) == 2 ps1_arn = "arn:aws:sso:::permissionSet/ssoins-1234567890/ps-1" + assert isinstance(result[ps1_arn], ExtendedDict) + assert isinstance(result[ps1_arn]["Name"], ExtendedString) assert ps1_arn in result assert result[ps1_arn]["Name"] == "AdminAccess" @@ -368,6 +429,9 @@ def test_list_account_assignments(self, aws_connector): unhump_assignments=False, ) + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) + assert isinstance(result[0]["AccountId"], ExtendedString) assert len(result) == 1 assert result[0]["AccountId"] == "123456789012" assert result[0]["PrincipalType"] == "USER" @@ -394,5 +458,39 @@ def test_create_account_assignment(self, aws_connector): principal_type="USER", ) + assert isinstance(result, ExtendedDict) + assert isinstance(result["AccountAssignmentCreationStatus"], ExtendedDict) assert "AccountAssignmentCreationStatus" in result mock_sso_admin.create_account_assignment.assert_called_once() + + def test_create_account_assignment_logs_redact_identifiers_but_preserve_call_args(self, aws_connector): + """Account assignment diagnostics should redact resource identifiers.""" + mock_sso_admin = MagicMock() + mock_sso_admin.list_instances.return_value = { + "Instances": [{"InstanceArn": "arn:aws:sso:::instance/ssoins-sensitive"}] + } + mock_sso_admin.create_account_assignment.return_value = { + "AccountAssignmentCreationStatus": { + "Status": "SUCCEEDED", + "RequestId": "req-123", + } + } + + aws_connector.get_aws_client = MagicMock(return_value=mock_sso_admin) + + aws_connector.create_account_assignment( + account_id="123456789012", + permission_set_arn="arn:aws:sso:::permissionSet/ssoins-sensitive/ps-sensitive", + principal_id="user-sensitive-1", + principal_type="USER", + ) + + call_args = mock_sso_admin.create_account_assignment.call_args.kwargs + assert call_args["TargetId"] == "123456789012" + assert call_args["PermissionSetArn"] == "arn:aws:sso:::permissionSet/ssoins-sensitive/ps-sensitive" + assert call_args["PrincipalId"] == "user-sensitive-1" + logs = _logged_text(aws_connector.logger) + assert "[REDACTED]" in logs + assert "123456789012" not in logs + assert "user-sensitive-1" not in logs + assert "ssoins-sensitive" not in logs diff --git a/tests/connectors/test_aws_tools.py b/tests/connectors/test_aws_tools.py index 2dfdb49..012dda4 100644 --- a/tests/connectors/test_aws_tools.py +++ b/tests/connectors/test_aws_tools.py @@ -2,13 +2,28 @@ from __future__ import annotations +import importlib.util + from unittest.mock import MagicMock, patch import pytest +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString, extend_data + + +# Patch where the tool functions instantiate the first-class connector. +AWS_CONNECTOR_PATCH = "extended_data.connectors.aws.AWSConnector" + -# Patch target for AWSConnectorFull - must patch where it's imported -AWS_CONNECTOR_PATCH = "extended_data.connectors.aws.AWSConnectorFull" +def test_aws_connector_requires_boto3_when_constructed_without_extra() -> None: + """AWS tool metadata imports without boto3, but the connector still requires the extra.""" + if importlib.util.find_spec("boto3") is not None: + pytest.skip("boto3 is installed") + + from extended_data.connectors.aws import AWSConnector + + with pytest.raises(ImportError, match=r"extended-data\[aws\]"): + AWSConnector(from_environment=False) class TestAWSToolDefinitions: @@ -38,6 +53,25 @@ def test_tool_names_prefixed(self): assert defn["name"].startswith("aws_"), f"Tool name not prefixed: {defn['name']}" +class TestGetCallerAccountId: + """Tests for get_caller_account_id tool.""" + + @patch(AWS_CONNECTOR_PATCH) + def test_get_caller_account_id(self, mock_connector_class): + """Test account ID lookup.""" + from extended_data.connectors.aws.tools import get_caller_account_id + + mock_connector = MagicMock() + mock_connector.get_caller_account_id.return_value = "123456789012" + mock_connector_class.return_value = mock_connector + + result = get_caller_account_id() + + assert isinstance(result, ExtendedDict) + assert isinstance(result["account_id"], ExtendedString) + assert result["account_id"] == "123456789012" + + class TestListSecrets: """Tests for list_secrets tool.""" @@ -47,22 +81,28 @@ def test_list_secrets_basic(self, mock_connector_class): from extended_data.connectors.aws.tools import list_secrets mock_connector = MagicMock() - mock_connector.list_secrets.return_value = { - "my-secret": { - "ARN": "arn:aws:secretsmanager:us-east-1:123456789012:secret:my-secret", - "Description": "Test secret", - "LastChangedDate": "2024-01-01T00:00:00Z", - }, - "another-secret": { - "ARN": "arn:aws:secretsmanager:us-east-1:123456789012:secret:another-secret", - "Description": "Another test", - "LastChangedDate": "2024-01-02T00:00:00Z", - }, - } + mock_connector.list_secrets.return_value = extend_data( + { + "my-secret": { + "ARN": "arn:aws:secretsmanager:us-east-1:123456789012:secret:my-secret", + "Description": "Test secret", + "LastChangedDate": "2024-01-01T00:00:00Z", + }, + "another-secret": { + "ARN": "arn:aws:secretsmanager:us-east-1:123456789012:secret:another-secret", + "Description": "Another test", + "LastChangedDate": "2024-01-02T00:00:00Z", + }, + } + ) mock_connector_class.return_value = mock_connector result = list_secrets() + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) + assert isinstance(result[0]["name"], ExtendedString) + assert isinstance(result[0]["value"], ExtendedDict) assert len(result) == 2 assert result[0]["name"] == "my-secret" assert "arn" in result[0] @@ -95,6 +135,8 @@ def test_get_secret_basic(self, mock_connector_class): result = get_secret("my-secret") + assert isinstance(result, ExtendedDict) + assert isinstance(result["secret_name"], ExtendedString) assert result["secret_name"] == "my-secret" assert result["secret_value"] == "super-secret-value" assert result["status"] == "retrieved" @@ -109,16 +151,21 @@ def test_list_s3_buckets_basic(self, mock_connector_class): from extended_data.connectors.aws.tools import list_s3_buckets mock_connector = MagicMock() - mock_connector.list_s3_buckets.return_value = { - "my-bucket": { - "CreationDate": "2024-01-01T00:00:00Z", - "region": "us-east-1", - }, - } + mock_connector.list_s3_buckets.return_value = extend_data( + { + "my-bucket": { + "CreationDate": "2024-01-01T00:00:00Z", + "region": "us-east-1", + }, + } + ) mock_connector_class.return_value = mock_connector result = list_s3_buckets() + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) + assert isinstance(result[0]["name"], ExtendedString) assert len(result) == 1 assert result[0]["name"] == "my-bucket" assert result[0]["region"] == "us-east-1" @@ -133,17 +180,22 @@ def test_list_s3_objects_basic(self, mock_connector_class): from extended_data.connectors.aws.tools import list_s3_objects mock_connector = MagicMock() - mock_connector.list_objects.return_value = { - "file1.txt": { - "Size": 1024, - "LastModified": "2024-01-01T00:00:00Z", - "StorageClass": "STANDARD", - }, - } + mock_connector.list_objects.return_value = extend_data( + { + "file1.txt": { + "Size": 1024, + "LastModified": "2024-01-01T00:00:00Z", + "StorageClass": "STANDARD", + }, + } + ) mock_connector_class.return_value = mock_connector result = list_s3_objects("my-bucket") + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) + assert isinstance(result[0]["key"], ExtendedString) assert len(result) == 1 assert result[0]["key"] == "file1.txt" assert result[0]["size"] == 1024 @@ -158,17 +210,22 @@ def test_list_accounts_basic(self, mock_connector_class): from extended_data.connectors.aws.tools import list_accounts mock_connector = MagicMock() - mock_connector.get_accounts.return_value = { - "123456789012": { - "Name": "Production", - "Email": "prod@example.com", - "Status": "ACTIVE", - }, - } + mock_connector.get_accounts.return_value = extend_data( + { + "123456789012": { + "Name": "Production", + "Email": "prod@example.com", + "Status": "ACTIVE", + }, + } + ) mock_connector_class.return_value = mock_connector result = list_accounts() + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) + assert isinstance(result[0]["name"], ExtendedString) assert len(result) == 1 assert result[0]["id"] == "123456789012" assert result[0]["name"] == "Production" @@ -183,17 +240,22 @@ def test_list_sso_users_basic(self, mock_connector_class): from extended_data.connectors.aws.tools import list_sso_users mock_connector = MagicMock() - mock_connector.list_sso_users.return_value = { - "user-123": { - "user_name": "john.doe", - "display_name": "John Doe", - "primary_email": {"value": "john@example.com"}, - }, - } + mock_connector.list_sso_users.return_value = extend_data( + { + "user-123": { + "user_name": "john.doe", + "display_name": "John Doe", + "primary_email": {"value": "john@example.com"}, + }, + } + ) mock_connector_class.return_value = mock_connector result = list_sso_users() + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) + assert isinstance(result[0]["user_name"], ExtendedString) assert len(result) == 1 assert result[0]["user_id"] == "user-123" assert result[0]["user_name"] == "john.doe" @@ -208,17 +270,22 @@ def test_list_sso_groups_basic(self, mock_connector_class): from extended_data.connectors.aws.tools import list_sso_groups mock_connector = MagicMock() - mock_connector.list_sso_groups.return_value = { - "group-123": { - "display_name": "Admins", - "description": "Admin group", - "members": ["user-1", "user-2"], - }, - } + mock_connector.list_sso_groups.return_value = extend_data( + { + "group-123": { + "display_name": "Admins", + "description": "Admin group", + "members": ["user-1", "user-2"], + }, + } + ) mock_connector_class.return_value = mock_connector result = list_sso_groups() + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) + assert isinstance(result[0]["display_name"], ExtendedString) assert len(result) == 1 assert result[0]["group_id"] == "group-123" assert result[0]["display_name"] == "Admins" diff --git a/tests/connectors/test_base.py b/tests/connectors/test_base.py new file mode 100644 index 0000000..7f70cda --- /dev/null +++ b/tests/connectors/test_base.py @@ -0,0 +1,392 @@ +"""Tests for base connector data helpers.""" + +from __future__ import annotations + +import builtins + +from unittest.mock import MagicMock + +import httpx +import pytest + +from pydantic import BaseModel, Field + +from extended_data.connectors.base import ConnectorAPIError, ConnectorBase, RateLimitError +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString +from extended_data.io import DataFile +from extended_data.logging import Logging +from extended_data.workflows import DataWorkflow + + +class ExampleConnector(ConnectorBase): + """Small connector used to exercise the base class.""" + + BASE_URL = "https://api.example.com" + + +def _connector() -> ExampleConnector: + logger = MagicMock(spec=Logging) + logger.logger = MagicMock() + return ExampleConnector(from_environment=False, logger=logger) + + +def test_connector_default_logging_does_not_create_cwd_log_file(tmp_path, monkeypatch) -> None: + """Default connector construction should not write log files as a side effect.""" + monkeypatch.chdir(tmp_path) + + connector = ExampleConnector(from_environment=False) + + assert connector.logging.enable_file is False + assert not (tmp_path / "ExampleConnector.log").exists() + + +def test_decode_response_promotes_json_to_extended_containers() -> None: + """JSON responses flow through the Tier 2 container bridge.""" + connector = _connector() + response = httpx.Response( + 200, + content=b'{"service":{"name":"api"}}', + headers={"content-type": "application/json; charset=utf-8"}, + ) + + data = connector.decode_response(response) + + assert isinstance(data, ExtendedDict) + assert isinstance(data["service"], ExtendedDict) + assert isinstance(data["service"]["name"], ExtendedString) + assert data["service"]["name"].upper_first() == "Api" + + +def test_decode_response_can_return_plain_json() -> None: + """Response decoding can opt out of extended containers.""" + connector = _connector() + response = httpx.Response( + 200, + content=b'{"service":{"name":"api"}}', + headers={"content-type": "application/vnd.example+json"}, + ) + + data = connector.decode_response(response, as_extended=False) + + assert data == {"service": {"name": "api"}} + assert not isinstance(data["service"]["name"], ExtendedString) + + +def test_decode_response_promotes_text_to_extended_string() -> None: + """Text responses become ExtendedString values by default.""" + connector = _connector() + response = httpx.Response( + 200, + content=b"api response", + headers={"content-type": "text/plain"}, + ) + + data = connector.decode_response(response) + + assert isinstance(data, ExtendedString) + assert data.to_snake_case() == "api_response" + + +def test_decode_response_preserves_unknown_binary_data() -> None: + """Unknown binary responses are left as bytes.""" + connector = _connector() + response = httpx.Response( + 200, + content=b"\x00\x01\x02", + headers={"content-type": "application/octet-stream"}, + ) + + assert connector.decode_response(response) == b"\x00\x01\x02" + + +def test_request_data_decodes_response_body() -> None: + """request_data combines the raw request primitive with response decoding.""" + connector = _connector() + mock_client = MagicMock() + mock_client.request.return_value = httpx.Response( + 200, + content=b'{"ok":true}', + headers={"content-type": "application/json"}, + ) + connector._client = mock_client + + data = connector.request_data("GET", "/status") + + assert data == {"ok": True} + assert isinstance(data, ExtendedDict) + mock_client.request.assert_called_once() + assert mock_client.request.call_args.args[0] == "GET" + assert mock_client.request.call_args.args[1] == "https://api.example.com/status" + + +def test_decode_response_file_returns_artifact_with_metadata() -> None: + """HTTP response artifacts retain decoded data and non-secret provenance.""" + connector = _connector() + response = httpx.Response( + 200, + content=b'{"service":{"name":"api"}}', + headers={"content-type": "application/json"}, + request=httpx.Request("GET", "https://api.example.com/status"), + ) + + artifact = connector.decode_response_file(response) + + assert isinstance(artifact, DataFile) + assert artifact.source == "https://api.example.com/status" + assert artifact.encoding == "json" + assert isinstance(artifact.data, ExtendedDict) + assert artifact.data["service"]["name"].upper_first() == "Api" + assert artifact.metadata["status_code"] == 200 + assert artifact.metadata["content_type"] == "application/json" + assert artifact.metadata["method"] == "GET" + + +def test_decode_response_file_preserves_unknown_binary_payload() -> None: + """Unknown binary API responses remain bytes inside the DataFile artifact.""" + connector = _connector() + response = httpx.Response( + 200, + content=b"\x00\x01\x02", + headers={"content-type": "application/octet-stream"}, + ) + + artifact = connector.decode_response_file(response, source="https://api.example.com/blob") + + assert isinstance(artifact, DataFile) + assert artifact.source == "https://api.example.com/blob" + assert artifact.encoding == "raw" + assert artifact.data == b"\x00\x01\x02" + assert artifact.metadata["data_type"] == "bytes" + assert artifact.metadata["status_code"] == 200 + + +def test_request_data_file_adds_request_provenance() -> None: + """request_data_file combines request, decoding, and artifact provenance.""" + connector = _connector() + mock_client = MagicMock() + mock_client.request.return_value = httpx.Response( + 200, + content=b'{"ok":true}', + headers={"content-type": "application/json"}, + ) + connector._client = mock_client + + artifact = connector.request_data_file("GET", "/status") + + assert isinstance(artifact, DataFile) + assert artifact.source == "https://api.example.com/status" + assert artifact.data == {"ok": True} + assert isinstance(artifact.data, ExtendedDict) + assert artifact.metadata["method"] == "GET" + assert artifact.metadata["endpoint"] == "/status" + mock_client.request.assert_called_once() + + +def test_request_workflow_starts_from_response_artifact() -> None: + """request_workflow should hand API data directly to DataWorkflow with provenance.""" + connector = _connector() + mock_client = MagicMock() + mock_client.request.return_value = httpx.Response( + 200, + content=b'{"HTTPResponseCode":"200","SelectedServices":["api","api","worker"]}', + headers={"content-type": "application/json"}, + ) + connector._client = mock_client + + workflow = connector.request_workflow("GET", "/status") + result = workflow.transform("reconstruct", "unhump", "deduplicate").result() + + assert isinstance(workflow, DataWorkflow) + assert workflow.steps == ("data_file:https://api.example.com/status",) + assert workflow.metadata["method"] == "GET" + assert workflow.metadata["endpoint"] == "/status" + assert result.metadata["status_code"] == 200 + assert result.as_builtin() == { + "http_response_code": 200, + "selected_services": ["api", "worker"], + } + mock_client.request.assert_called_once() + + +@pytest.mark.parametrize( + ("helper_name", "expected_method"), + [ + ("get_workflow", "GET"), + ("post_workflow", "POST"), + ("put_workflow", "PUT"), + ("patch_workflow", "PATCH"), + ("delete_workflow", "DELETE"), + ], +) +def test_http_verb_workflow_helpers_start_response_workflows(helper_name: str, expected_method: str) -> None: + """Verb-specific workflow helpers should mirror decoded data helpers.""" + connector = _connector() + mock_client = MagicMock() + mock_client.request.return_value = httpx.Response( + 200, + content=b'{"ok":true}', + headers={"content-type": "application/json"}, + ) + connector._client = mock_client + + workflow = getattr(connector, helper_name)("/status") + + assert isinstance(workflow, DataWorkflow) + assert workflow.metadata["method"] == expected_method + assert workflow.metadata["endpoint"] == "/status" + assert workflow.result().as_builtin() == {"ok": True} + mock_client.request.assert_called_once() + assert mock_client.request.call_args.args[0] == expected_method + assert mock_client.request.call_args.args[1] == "https://api.example.com/status" + + +def test_extend_result_promotes_connector_payloads() -> None: + """Connector data payloads cross into the Tier 2 container layer explicitly.""" + connector = _connector() + + data = connector.extend_result({"service": {"name": "api"}, "tags": ["core"]}) + + assert isinstance(data, ExtendedDict) + assert isinstance(data["service"], ExtendedDict) + assert isinstance(data["service"]["name"], ExtendedString) + assert isinstance(data["tags"], ExtendedList) + assert data["service"]["name"].upper_first() == "Api" + + +def test_handle_ai_tool_call_promotes_result_payloads() -> None: + """AI tool dispatch should expose extended containers, not raw dict payloads.""" + connector = _connector() + connector.register_tool(lambda: {"status": "ok", "items": ["one"]}, name="status") + + result = connector.handle_ai_tool_call("status", {}) + + assert isinstance(result, ExtendedDict) + assert isinstance(result["items"], ExtendedList) + assert result["status"].upper_first() == "Ok" + + +def test_handle_ai_tool_call_redacts_unknown_tool_names() -> None: + """Unknown AI tool diagnostics should not echo secret-bearing names.""" + connector = _connector() + + with pytest.raises(ValueError) as exc_info: + connector.handle_ai_tool_call("password=hunter2 Authorization: Bearer raw_token", {}) + + message = str(exc_info.value) + assert "hunter2" not in message + assert "raw_token" not in message + assert "[REDACTED]" in message + + +def test_get_ai_tool_definitions_promotes_definition_payloads() -> None: + """AI tool definition export should expose extended containers.""" + + class StatusArgs(BaseModel): + verbose: bool = Field(..., description="Include detailed status.") + + def status(verbose: bool) -> dict[str, str]: + """Read service status.""" + return {"status": "ok" if verbose else "quiet"} + + connector = _connector() + connector.register_tool(status, name="status", schema=StatusArgs) + + definitions = connector.get_ai_tool_definitions() + + assert isinstance(definitions, ExtendedList) + assert isinstance(definitions[0], ExtendedDict) + assert definitions[0]["name"] == "status" + assert isinstance(definitions[0]["inputSchema"], ExtendedDict) + assert isinstance(definitions[0]["inputSchema"]["properties"]["verbose"]["description"], ExtendedString) + + +def test_request_uses_connector_max_retries(mocker) -> None: + """Connector subclasses control the retry attempt count.""" + + class TwoAttemptConnector(ExampleConnector): + MAX_RETRIES = 2 + + connector = TwoAttemptConnector(from_environment=False) + mocker.patch("extended_data.connectors.base.time.sleep") + mock_client = MagicMock() + mock_client.request.side_effect = [ + httpx.Response(500, content=b"temporary failure"), + httpx.Response(200, content=b"ok"), + ] + connector._client = mock_client + + response = connector.request("GET", "/status") + + assert response.status_code == 200 + assert mock_client.request.call_count == 2 + + +def test_request_rejects_invalid_max_retries() -> None: + """Invalid retry configuration fails before issuing a request.""" + + class InvalidRetryConnector(ExampleConnector): + MAX_RETRIES = 0 + + connector = InvalidRetryConnector(from_environment=False) + mock_client = MagicMock() + connector._client = mock_client + + with pytest.raises(ValueError, match="MAX_RETRIES must be at least 1"): + connector.request("GET", "/status") + + mock_client.request.assert_not_called() + + +def test_request_once_redacts_sensitive_client_error_body() -> None: + """Programmatic connector API errors should not expose raw secret-bearing bodies.""" + connector = _connector() + mock_client = MagicMock() + mock_client.request.return_value = httpx.Response( + 401, + content=b'{"password":"hunter2","message":"Authorization: Bearer raw_token"}', + ) + connector._client = mock_client + + with pytest.raises(ConnectorAPIError) as exc_info: + connector._request_once("GET", "/status") + + message = str(exc_info.value) + assert exc_info.value.status_code == 401 + assert "hunter2" not in message + assert "raw_token" not in message + assert "[REDACTED]" in message + + +def test_request_once_redacts_sensitive_server_error_body() -> None: + """Retry-triggering server errors should not carry raw response secrets.""" + connector = _connector() + mock_client = MagicMock() + mock_client.request.return_value = httpx.Response( + 500, + content=b'{"api_key":"key_123","message":"Bearer raw_token"}', + ) + connector._client = mock_client + + with pytest.raises(RateLimitError) as exc_info: + connector._request_once("GET", "/status") + + message = str(exc_info.value) + assert "key_123" not in message + assert "raw_token" not in message + assert "[REDACTED]" in message + + +def test_get_tools_requires_langchain_extra(monkeypatch) -> None: + """Base LangChain tool export should fail visibly when langchain-core is missing.""" + connector = _connector() + original_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "langchain_core.tools": + raise ImportError("blocked langchain-core") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + + with pytest.raises(ImportError, match=r"extended-data\[langchain\]"): + connector.get_tools() diff --git a/tests/connectors/test_cli.py b/tests/connectors/test_cli.py index f283b75..929016f 100644 --- a/tests/connectors/test_cli.py +++ b/tests/connectors/test_cli.py @@ -3,31 +3,373 @@ from __future__ import annotations import argparse +import json -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest -from extended_data.connectors.cli import cmd_list, main +from extended_data.connectors import cli as cli_module +from extended_data.connectors.cli import cmd_call, cmd_info, cmd_list, cmd_methods, main +from extended_data.containers import ExtendedDict -@pytest.mark.xfail(reason="Pre-existing mock issue: cmd_list uses logging instead of print") -def test_cli_list(): +class ExampleConnector: + """Tiny connector shell for CLI call-surface tests.""" + + def fetch(self, enabled: bool = False, count: int = 0) -> ExtendedDict: + """Fetch example data.""" + return ExtendedDict({"enabled": enabled, "count": count}) + + def secrets(self) -> ExtendedDict: + """Fetch example sensitive data.""" + return ExtendedDict( + { + "password": "hunter2", + "access_token": "tok_123", + "id_token": 12345, + "nested": {"api_key": "key_456"}, + "ok": True, + } + ) + + +def test_cli_list() -> None: """Test the list command.""" - args = argparse.Namespace(json=False) - with patch("builtins.print") as mock_print: + args = argparse.Namespace(json=False, available_only=False, category=None, capability=None) + with patch("sys.stdout.write") as mock_write: exit_code = cmd_list(args) assert exit_code == 0 - mock_print.assert_called() + mock_write.assert_called() # Verify it lists some connectors - output = "\n".join(call.args[0] for call in mock_print.call_args_list if call.args) + output = "".join(call.args[0] for call in mock_write.call_args_list if call.args) assert "aws" in output assert "google" in output + assert "category" in output + assert "capabilities" in output + assert "cloud" in output + + +def test_cli_list_json() -> None: + """List command can emit machine-readable connector metadata.""" + args = argparse.Namespace(json=True, available_only=False, category=None, capability=None) + with patch("sys.stdout.write") as mock_write: + exit_code = cmd_list(args) + + assert exit_code == 0 + output = mock_write.call_args.args[0] + assert '"name": "github"' in output + assert '"available":' in output + assert '"category": "development"' in output + assert '"capabilities":' in output + assert "api_key_env" not in output + + +def test_cli_list_filters_by_category() -> None: + """List command can filter the connector catalog by category.""" + args = argparse.Namespace(json=False, available_only=False, category="cloud", capability=None) + with patch("sys.stdout.write") as mock_write: + exit_code = cmd_list(args) + + assert exit_code == 0 + output = "".join(call.args[0] for call in mock_write.call_args_list if call.args) + assert "aws" in output + assert "google" in output + assert "github" not in output + + +def test_cli_list_filters_by_capability_json() -> None: + """List command can emit capability-filtered connector metadata.""" + args = argparse.Namespace(json=True, available_only=False, category=None, capability="repositories") + with patch("sys.stdout.write") as mock_write: + exit_code = cmd_list(args) + + assert exit_code == 0 + entries = json.loads(mock_write.call_args.args[0]) + names = {entry["name"] for entry in entries} + assert "github" in names + assert "cursor" in names + assert "aws" not in names + + +def test_cli_list_intersects_category_and_capability_filters() -> None: + """Category and capability filters should narrow the same catalog result.""" + args = argparse.Namespace(json=True, available_only=False, category="ai", capability="repositories") + with patch("sys.stdout.write") as mock_write: + exit_code = cmd_list(args) + + assert exit_code == 0 + entries = json.loads(mock_write.call_args.args[0]) + assert [entry["name"] for entry in entries] == ["cursor"] + + +def test_cli_info() -> None: + """Info command prints connector metadata.""" + args = argparse.Namespace(connector=" github ", json=False) + with patch("sys.stdout.write") as mock_write: + exit_code = cmd_info(args) + + assert exit_code == 0 + output = "".join(call.args[0] for call in mock_write.call_args_list if call.args) + assert "name: github" in output + assert "category: development" in output + assert "capabilities: repositories, teams, files, graphql, workflows" in output + assert "install: pip install extended-data[github]" in output + + +def test_cli_methods_lists_public_methods() -> None: + """Methods command prints public data methods with descriptions.""" + args = argparse.Namespace(connector="meshy") + with patch("sys.stdout.write") as mock_write: + exit_code = cmd_methods(args) + + assert exit_code == 0 + output = "".join(call.args[0] for call in mock_write.call_args_list if call.args) + assert "text3d_generate" in output + assert "request_data" not in output + assert "decode_response" not in output + + +def test_cli_methods_json_lists_public_methods() -> None: + """Methods command can emit machine-readable data-method metadata.""" + args = argparse.Namespace(connector="meshy", json=True) + with patch("sys.stdout.write") as mock_write: + exit_code = cmd_methods(args) + + assert exit_code == 0 + methods = json.loads(mock_write.call_args.args[0]) + method_names = {method["name"] for method in methods} + assert "text3d_generate" in method_names + assert "request_data" not in method_names + + +def test_cli_call_parses_dynamic_keyword_arguments() -> None: + """Call command accepts documented --arg value pairs after the method.""" + connector = MagicMock() + connector.fetch.return_value = {"ok": True} + + with ( + patch("sys.argv", ["extended-data", "call", "example", "fetch", "--enabled", "true", "--count", "3"]), + patch("extended_data.connectors.cli.get_connector_class", return_value=ExampleConnector), + patch("extended_data.connectors.cli.get_connector", return_value=connector), + patch("sys.stdout.write") as mock_write, + ): + exit_code = main() + + assert exit_code == 0 + connector.fetch.assert_called_once_with(enabled=True, count=3) + output = "".join(call.args[0] for call in mock_write.call_args_list if call.args) + assert '"ok": true' in output + + +def test_cli_call_accepts_json_flag_after_method() -> None: + """Call command treats trailing --json as a CLI flag, not a method kwarg.""" + connector = MagicMock() + connector.fetch.return_value = {"ok": True} + args = argparse.Namespace(connector="example", method="fetch", extra=["--json"], json=False) + + with ( + patch("extended_data.connectors.cli.get_connector_class", return_value=ExampleConnector), + patch("extended_data.connectors.cli.get_connector", return_value=connector), + patch("sys.stdout.write") as mock_write, + ): + exit_code = cmd_call(args) + assert exit_code == 0 + connector.fetch.assert_called_once_with() + assert '"ok": true' in mock_write.call_args.args[0] -def test_cli_main_help(): + +def test_cli_call_serializes_extended_containers_as_data() -> None: + """Call command renders Tier 2 containers as JSON data, not iterable keys.""" + connector = MagicMock() + connector.fetch.return_value = ExtendedDict({"service": {"name": "api"}}) + args = argparse.Namespace(connector="example", method="fetch", extra=[], json=True) + + with ( + patch("extended_data.connectors.cli.get_connector_class", return_value=ExampleConnector), + patch("extended_data.connectors.cli.get_connector", return_value=connector), + patch("extended_data.connectors.cli.wrap_raw_data_for_export", wraps=cli_module.wrap_raw_data_for_export) + as mock_wrap_for_export, + patch("sys.stdout.write") as mock_write, + ): + exit_code = cmd_call(args) + + assert exit_code == 0 + assert json.loads(mock_write.call_args.args[0]) == {"service": {"name": "api"}} + mock_wrap_for_export.assert_called_once() + assert mock_wrap_for_export.call_args.kwargs == {"allow_encoding": "json", "indent_2": True, "default": str} + + +def test_cli_call_redacts_sensitive_json_output() -> None: + """Call command should not write common secret fields to stdout.""" + connector = MagicMock() + connector.secrets.return_value = ExampleConnector().secrets() + args = argparse.Namespace(connector="example", method="secrets", extra=[], json=True) + + with ( + patch("extended_data.connectors.cli.get_connector_class", return_value=ExampleConnector), + patch("extended_data.connectors.cli.get_connector", return_value=connector), + patch("sys.stdout.write") as mock_write, + ): + exit_code = cmd_call(args) + + assert exit_code == 0 + output = mock_write.call_args.args[0] + assert "hunter2" not in output + assert "tok_123" not in output + assert "12345" not in output + assert "key_456" not in output + assert json.loads(output)["id_token"] == "[REDACTED]" + assert '"password": "[REDACTED]"' in output + assert '"access_token": "[REDACTED]"' in output + assert '"api_key": "[REDACTED]"' in output + + +def test_cli_call_reports_missing_method() -> None: + """Call command reports missing methods instead of failing silently.""" + args = argparse.Namespace(connector="example", method="missing", extra=[], json=False) + connector = object() + + with ( + patch("extended_data.connectors.cli.get_connector_class", return_value=ExampleConnector), + patch("extended_data.connectors.cli.get_connector", return_value=connector), + patch("sys.stderr.write") as mock_write, + ): + exit_code = cmd_call(args) + + assert exit_code == 1 + assert "has no exposed data method" in mock_write.call_args.args[0] + + +def test_cli_call_rejects_raw_connector_helpers() -> None: + """Call command should not expose raw/base helpers at the serialization boundary.""" + args = argparse.Namespace(connector="meshy", method="request_data", extra=[], json=False) + + with patch("sys.stderr.write") as mock_write: + exit_code = cmd_call(args) + + assert exit_code == 1 + assert "has no exposed data method" in mock_write.call_args.args[0] + + +def test_cli_call_reports_connector_errors() -> None: + """Call command writes connector errors to stderr.""" + args = argparse.Namespace(connector="example", method="fetch", extra=[], json=False) + + with ( + patch("extended_data.connectors.cli.get_connector_class", return_value=ExampleConnector), + patch("extended_data.connectors.cli.get_connector", side_effect=RuntimeError("boom")), + patch("sys.stderr.write") as mock_write, + ): + exit_code = cmd_call(args) + + assert exit_code == 1 + assert "boom" in mock_write.call_args.args[0] + + +def test_cli_call_redacts_sensitive_error_output() -> None: + """Call command should sanitize common secret values in stderr.""" + args = argparse.Namespace(connector="example", method="fetch", extra=[], json=False) + error = RuntimeError("failed password=hunter2 token: tok_123 Authorization: Bearer raw_token") + + with ( + patch("extended_data.connectors.cli.get_connector_class", return_value=ExampleConnector), + patch("extended_data.connectors.cli.get_connector", side_effect=error), + patch("sys.stderr.write") as mock_write, + ): + exit_code = cmd_call(args) + + assert exit_code == 1 + output = mock_write.call_args.args[0] + assert "hunter2" not in output + assert "tok_123" not in output + assert "raw_token" not in output + assert "password=[REDACTED]" in output + assert "token: [REDACTED]" in output + assert "Authorization: [REDACTED]" in output + + +def test_cli_call_redacts_explicit_argument_values_from_errors() -> None: + """Call command should redact caller-provided resource context in stderr.""" + args = argparse.Namespace( + connector="example", + method="fetch", + extra=[ + "--email", + "private-user@example.com", + "--metadata", + '{"path": "/tmp/private/path", "prompt": "Fix login"}', + ], + json=False, + ) + connector = MagicMock() + connector.fetch.side_effect = RuntimeError( + "failed for private-user@example.com at /tmp/private%2Fpath while handling Fix login" + ) + + with ( + patch("extended_data.connectors.cli.get_connector_class", return_value=ExampleConnector), + patch("extended_data.connectors.cli.get_connector", return_value=connector), + patch("sys.stderr.write") as mock_write, + ): + exit_code = cmd_call(args) + + assert exit_code == 1 + connector.fetch.assert_called_once_with( + email="private-user@example.com", + metadata={"path": "/tmp/private/path", "prompt": "Fix login"}, + ) + output = mock_write.call_args.args[0] + assert "private-user@example.com" not in output + assert "/tmp/private%2Fpath" not in output + assert "Fix login" not in output + assert output.count("[REDACTED]") >= 3 + + +@patch("extended_data.connectors.cli.decode_file", wraps=cli_module.decode_file) +def test_cli_call_decodes_json_arguments_through_data_boundary(mock_decode_file: MagicMock) -> None: + """Structured CLI method arguments should use the shared data decoder.""" + args = argparse.Namespace( + connector="example", + method="fetch", + extra=["--metadata", '{"service": {"name": "api"}}'], + json=False, + ) + connector = MagicMock() + connector.fetch.return_value = {"ok": True} + + with ( + patch("extended_data.connectors.cli.get_connector_class", return_value=ExampleConnector), + patch("extended_data.connectors.cli.get_connector", return_value=connector), + patch("sys.stdout.write"), + ): + exit_code = cmd_call(args) + + assert exit_code == 0 + connector.fetch.assert_called_once_with(metadata={"service": {"name": "api"}}) + mock_decode_file.assert_called_once_with('{"service": {"name": "api"}}', suffix="json", as_extended=False) + + +def test_cli_main_help() -> None: """Test main CLI entry point with help.""" with patch("sys.argv", ["extended-data", "--help"]): with pytest.raises(SystemExit) as exc: main() assert exc.value.code == 0 + + +def test_cli_main_reports_unexpected_command_errors() -> None: + """Connector CLI entrypoint should not collapse unexpected failures silently.""" + with ( + patch("sys.argv", ["extended-data", "list"]), + patch("extended_data.connectors.cli.cmd_list", side_effect=RuntimeError("failed password=hunter2")), + patch("sys.stderr.write") as mock_write, + ): + exit_code = main() + + assert exit_code == 1 + output = mock_write.call_args.args[0] + assert "failed" in output + assert "hunter2" not in output + assert "password=[REDACTED]" in output diff --git a/tests/connectors/test_cloud_params.py b/tests/connectors/test_cloud_params.py index a590af0..e0067fc 100644 --- a/tests/connectors/test_cloud_params.py +++ b/tests/connectors/test_cloud_params.py @@ -7,6 +7,7 @@ get_cloud_call_params, get_google_call_params, ) +from extended_data.containers import ExtendedDict, ExtendedString class TestGetCloudCallParams: @@ -15,6 +16,7 @@ class TestGetCloudCallParams: def test_default_max_results(self): """Default max_results is 10.""" params = get_cloud_call_params() + assert isinstance(params, ExtendedDict) assert params == {"MaxResults": 10} def test_custom_max_results(self): @@ -35,6 +37,7 @@ def test_max_results_zero(self): def test_kwargs_included(self): """Additional kwargs are included.""" params = get_cloud_call_params(NextToken="abc123") + assert isinstance(params["NextToken"], ExtendedString) assert params == {"MaxResults": 10, "NextToken": "abc123"} def test_reject_null_values(self): @@ -64,6 +67,7 @@ class TestGetAwsCallParams: def test_default_max_results(self): """AWS default max_results is 100.""" params = get_aws_call_params() + assert isinstance(params, ExtendedDict) assert params == {"MaxResults": 100} def test_first_letter_upper(self): @@ -91,6 +95,7 @@ class TestGetGoogleCallParams: def test_default_max_results(self): """Google default max_results is 200.""" params = get_google_call_params() + assert isinstance(params, ExtendedDict) assert params == {"maxResults": 200} def test_first_letter_lower(self): diff --git a/tests/connectors/test_connector_payload_contracts.py b/tests/connectors/test_connector_payload_contracts.py new file mode 100644 index 0000000..cc9c430 --- /dev/null +++ b/tests/connectors/test_connector_payload_contracts.py @@ -0,0 +1,403 @@ +"""Contracts for direct connector payload surfaces.""" + +from __future__ import annotations + +import ast + +from pathlib import Path +from typing import Any, get_args, get_origin, get_type_hints + +import pytest + +import extended_data.connectors as connector_exports + +from extended_data.connectors.anthropic import AnthropicConnector +from extended_data.connectors.aws import AWSConnector +from extended_data.connectors.aws.codedeploy import create_codedeploy_deployment, get_aws_codedeploy_deployments +from extended_data.connectors.aws.organizations import AWSOrganizationsMixin +from extended_data.connectors.aws.s3 import AWSS3Mixin +from extended_data.connectors.aws.sso import AWSSSOmixin +from extended_data.connectors.base import ConnectorBase +from extended_data.connectors.cursor import CursorConnector +from extended_data.connectors.github import GitHubConnector +from extended_data.connectors.google import GoogleConnector +from extended_data.connectors.google.billing import GoogleBillingMixin +from extended_data.connectors.google.cloud import GoogleCloudMixin +from extended_data.connectors.google.jules import JulesConnector +from extended_data.connectors.google.services import GoogleServicesMixin +from extended_data.connectors.google.workspace import GoogleWorkspaceMixin +from extended_data.connectors.meshy.connector import MeshyConnector +from extended_data.connectors.registry import BUILTIN_CONNECTORS +from extended_data.connectors.secrets import SecretsConnector +from extended_data.connectors.slack import SlackConnector +from extended_data.connectors.surface import connector_data_methods, is_connector_data_method +from extended_data.connectors.vault import VaultConnector +from extended_data.connectors.zoom import ZoomConnector +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString, ExtendedTuple +from extended_data.inputs import InputProvider + + +REPO_ROOT = Path(__file__).resolve().parents[2] + +PAYLOAD_METHODS = ( + (AnthropicConnector.create_message, ExtendedDict), + (AnthropicConnector.list_models, ExtendedList[ExtendedDict]), + (AnthropicConnector.get_model, ExtendedDict), + (AWSConnector.get_caller_account_id, ExtendedString), + (AWSConnector.get_secret, ExtendedString | None), + (AWSConnector.list_secrets, ExtendedDict), + (AWSConnector.create_secret, ExtendedDict), + (AWSConnector.update_secret, ExtendedDict), + (AWSConnector.delete_secret, ExtendedDict), + (AWSConnector.delete_secrets_matching, ExtendedList[ExtendedString]), + (AWSConnector.copy_secrets_to_s3, ExtendedString), + (AWSConnector.load_secrets_by_prefix, ExtendedDict), + (AWSOrganizationsMixin.get_organization_accounts, ExtendedDict), + (AWSOrganizationsMixin.get_controltower_accounts, ExtendedDict), + (AWSOrganizationsMixin.get_accounts, ExtendedDict), + (AWSOrganizationsMixin.get_organization_units, ExtendedDict), + (AWSOrganizationsMixin.classify_accounts, ExtendedDict), + (AWSOrganizationsMixin.label_aws_accounts, ExtendedDict), + (AWSOrganizationsMixin.label_aws_account, ExtendedDict), + (AWSOrganizationsMixin.classify_aws_accounts, ExtendedDict), + (AWSOrganizationsMixin.preprocess_aws_organization, ExtendedDict), + (AWSOrganizationsMixin.preprocess_organization, ExtendedDict), + (AWSS3Mixin.list_s3_buckets, ExtendedDict), + (AWSS3Mixin.get_bucket_location, ExtendedString), + (AWSS3Mixin.get_object, ExtendedString | bytes | None), + (AWSS3Mixin.get_json_object, ExtendedDict | ExtendedList[Any] | None), + (AWSS3Mixin.put_object, ExtendedDict), + (AWSS3Mixin.put_json_object, ExtendedDict), + (AWSS3Mixin.delete_object, ExtendedDict), + (AWSS3Mixin.list_objects, ExtendedList[ExtendedDict]), + (AWSS3Mixin.copy_object, ExtendedDict), + (AWSS3Mixin.get_bucket_features, ExtendedDict), + (AWSS3Mixin.find_buckets_by_name, ExtendedDict), + (AWSS3Mixin.create_bucket, ExtendedDict), + (AWSS3Mixin.get_bucket_tags, ExtendedDict), + (AWSS3Mixin.get_bucket_sizes, ExtendedDict), + (AWSSSOmixin.get_identity_store_id, ExtendedString), + (AWSSSOmixin.get_sso_instance_arn, ExtendedString), + (AWSSSOmixin.list_sso_users, ExtendedDict), + (AWSSSOmixin.get_sso_user, ExtendedDict | None), + (AWSSSOmixin.create_sso_user, ExtendedDict), + (AWSSSOmixin.list_sso_groups, ExtendedDict), + (AWSSSOmixin.create_sso_group, ExtendedDict), + (AWSSSOmixin.add_user_to_group, ExtendedDict), + (AWSSSOmixin.list_permission_sets, ExtendedDict), + (AWSSSOmixin.list_account_assignments, ExtendedList[ExtendedDict]), + (AWSSSOmixin.create_account_assignment, ExtendedDict), + (AWSSSOmixin.delete_account_assignment, ExtendedDict), + (get_aws_codedeploy_deployments, ExtendedDict), + (create_codedeploy_deployment, ExtendedDict), + (CursorConnector.list_agents, ExtendedList[ExtendedDict]), + (CursorConnector.get_agent_status, ExtendedDict), + (CursorConnector.get_agent_conversation, ExtendedDict), + (CursorConnector.launch_agent, ExtendedDict), + (CursorConnector.list_repositories, ExtendedList[ExtendedDict]), + (CursorConnector.list_models, ExtendedList[ExtendedString]), + (GitHubConnector.list_org_members, ExtendedDict), + (GitHubConnector.get_org_member, ExtendedDict | None), + (GitHubConnector.list_repositories, ExtendedDict), + (GitHubConnector.get_repository, ExtendedDict | None), + (GitHubConnector.list_teams, ExtendedDict), + (GitHubConnector.get_team, ExtendedDict | None), + (GitHubConnector.get_repository_file, ExtendedDict | ExtendedList[Any] | ExtendedString | ExtendedTuple[Any] | None), + (GitHubConnector.execute_graphql, ExtendedDict), + (GitHubConnector.get_users_with_verified_emails, ExtendedDict), + (GitHubConnector.build_workflow, ExtendedDict), + (GitHubConnector.build_workflow_job, ExtendedDict), + (GitHubConnector.build_workflow_step, ExtendedDict), + (GitHubConnector.create_python_ci_workflow, ExtendedDict), + (GoogleConnector.list_users, ExtendedList[ExtendedDict] | ExtendedDict), + (GoogleConnector.list_groups, ExtendedList[ExtendedDict] | ExtendedDict), + (GoogleBillingMixin.list_billing_accounts, ExtendedList[ExtendedDict]), + (GoogleBillingMixin.get_billing_account, ExtendedDict | None), + (GoogleBillingMixin.get_project_billing_info, ExtendedDict | None), + (GoogleBillingMixin.update_project_billing_info, ExtendedDict), + (GoogleBillingMixin.disable_project_billing, ExtendedDict), + (GoogleBillingMixin.list_billing_account_projects, ExtendedList[ExtendedDict]), + (GoogleBillingMixin.get_billing_account_iam_policy, ExtendedDict), + (GoogleBillingMixin.set_billing_account_iam_policy, ExtendedDict), + (GoogleBillingMixin.get_bigquery_billing_dataset, ExtendedDict | None), + (GoogleBillingMixin.setup_billing_export, ExtendedDict), + (GoogleCloudMixin.get_organization_id, ExtendedString), + (GoogleCloudMixin.get_organization, ExtendedDict), + (GoogleCloudMixin.list_projects, ExtendedList[ExtendedDict]), + (GoogleCloudMixin.get_project, ExtendedDict | None), + (GoogleCloudMixin.create_project, ExtendedDict), + (GoogleCloudMixin.delete_project, ExtendedDict), + (GoogleCloudMixin.move_project, ExtendedDict), + (GoogleCloudMixin.list_folders, ExtendedList[ExtendedDict]), + (GoogleCloudMixin.get_org_policy, ExtendedDict | None), + (GoogleCloudMixin.set_org_policy, ExtendedDict), + (GoogleCloudMixin.get_iam_policy, ExtendedDict), + (GoogleCloudMixin.set_iam_policy, ExtendedDict), + (GoogleCloudMixin.add_iam_binding, ExtendedDict), + (GoogleCloudMixin.list_service_accounts, ExtendedList[ExtendedDict]), + (GoogleCloudMixin.create_service_account, ExtendedDict), + (GoogleWorkspaceMixin.list_workspace_users, ExtendedList[ExtendedDict]), + (GoogleWorkspaceMixin.get_user, ExtendedDict | None), + (GoogleWorkspaceMixin.create_user, ExtendedDict), + (GoogleWorkspaceMixin.update_user, ExtendedDict), + (GoogleWorkspaceMixin.list_workspace_groups, ExtendedList[ExtendedDict]), + (GoogleWorkspaceMixin.get_group, ExtendedDict | None), + (GoogleWorkspaceMixin.create_group, ExtendedDict), + (GoogleWorkspaceMixin.list_group_members, ExtendedList[ExtendedDict]), + (GoogleWorkspaceMixin.add_group_member, ExtendedDict), + (GoogleWorkspaceMixin.list_org_units, ExtendedList[ExtendedDict]), + (GoogleWorkspaceMixin.create_or_update_user, ExtendedDict), + (GoogleWorkspaceMixin.create_or_update_group, ExtendedDict), + (GoogleWorkspaceMixin.list_available_licenses, ExtendedList[ExtendedDict]), + (GoogleWorkspaceMixin.get_license_summary, ExtendedDict), + (GoogleServicesMixin.list_compute_instances, ExtendedList[ExtendedDict]), + (GoogleServicesMixin.list_gke_clusters, ExtendedList[ExtendedDict]), + (GoogleServicesMixin.get_gke_cluster, ExtendedDict | None), + (GoogleServicesMixin.list_storage_buckets, ExtendedList[ExtendedDict]), + (GoogleServicesMixin.list_sql_instances, ExtendedList[ExtendedDict]), + (GoogleServicesMixin.list_pubsub_topics, ExtendedList[ExtendedDict]), + (GoogleServicesMixin.list_pubsub_subscriptions, ExtendedList[ExtendedDict]), + (GoogleServicesMixin.list_enabled_services, ExtendedList[ExtendedDict]), + (GoogleServicesMixin.enable_service, ExtendedDict), + (GoogleServicesMixin.disable_service, ExtendedDict), + (GoogleServicesMixin.batch_enable_services, ExtendedDict), + (GoogleServicesMixin.list_kms_keyrings, ExtendedList[ExtendedDict]), + (GoogleServicesMixin.create_kms_keyring, ExtendedDict), + (GoogleServicesMixin.create_kms_key, ExtendedDict), + (GoogleServicesMixin.get_project_iam_users, ExtendedDict), + (GoogleServicesMixin.get_pubsub_resources_for_project, ExtendedDict), + (GoogleServicesMixin.find_inactive_projects, ExtendedList[ExtendedDict]), + (JulesConnector.list_sources, ExtendedList[ExtendedDict]), + (JulesConnector.create_session, ExtendedDict), + (JulesConnector.get_session, ExtendedDict), + (JulesConnector.list_sessions, ExtendedList[ExtendedDict]), + (JulesConnector.approve_plan, ExtendedDict), + (JulesConnector.add_user_response, ExtendedDict), + (JulesConnector.resume_session, ExtendedDict), + (MeshyConnector.text3d_generate, ExtendedDict | ExtendedString), + (MeshyConnector.image3d_generate, ExtendedDict | ExtendedString), + (MeshyConnector.rig_model, ExtendedDict | ExtendedString), + (MeshyConnector.apply_animation, ExtendedDict | ExtendedString), + (MeshyConnector.retexture_model, ExtendedDict | ExtendedString), + (SecretsConnector.validate_config, ExtendedDict), + (SecretsConnector.get_config_info, ExtendedDict), + (SecretsConnector.run_pipeline, ExtendedDict), + (SecretsConnector.dry_run, ExtendedDict), + (SecretsConnector.merge, ExtendedDict), + (SecretsConnector.sync, ExtendedDict), + (SecretsConnector.get_targets, ExtendedDict), + (SecretsConnector.get_sources, ExtendedDict), + (SlackConnector.send_message, ExtendedString | ExtendedDict), + (SlackConnector.get_bot_channels, ExtendedDict), + (SlackConnector.list_users, ExtendedDict), + (SlackConnector.list_usergroups, ExtendedDict), + (SlackConnector.list_conversations, ExtendedDict), + (VaultConnector.list_secrets, ExtendedDict), + (VaultConnector.read_secret, ExtendedDict | None), + (VaultConnector.get_secret, ExtendedDict | None), + (VaultConnector.list_aws_iam_roles, ExtendedList[ExtendedString]), + (VaultConnector.get_aws_iam_role, ExtendedDict | None), + (VaultConnector.generate_aws_credentials, ExtendedDict), + (ZoomConnector.list_users, ExtendedDict), + (ZoomConnector.get_user, ExtendedDict), + (ZoomConnector.list_meetings, ExtendedList[ExtendedDict]), + (ZoomConnector.get_meeting, ExtendedDict), +) + +RAW_CONNECTOR_BOUNDARIES = { + ("src/extended_data/connectors/ai_tools.py", "build_langchain_tools"), + ("src/extended_data/connectors/base.py", "ConnectorBase.get_tools"), + ("src/extended_data/connectors/surface.py", "connector_data_methods"), + ("src/extended_data/connectors/zoom/__init__.py", "ZoomConnector.get_headers"), +} + +RAW_DATA_SURFACE_METHOD_NAMES = { + "close", + "decode_response_file", + "delete", + "delete_data", + "delete_workflow", + "download", + "extend_result", + "freeze_inputs", + "get", + "get_ai_tool_definitions", + "get_data", + "get_input", + "get_tools", + "get_workflow", + "handle_ai_tool_call", + "merge_inputs", + "patch", + "patch_data", + "patch_workflow", + "post", + "post_data", + "post_workflow", + "put", + "put_data", + "put_workflow", + "replace_inputs", + "request", + "request_data", + "request_data_file", + "request_workflow", + "snapshot_inputs", +} + +RAW_DATA_SURFACE_METHODS = ( + ConnectorBase.close, + ConnectorBase.decode_response_file, + ConnectorBase.delete, + ConnectorBase.delete_data, + ConnectorBase.delete_workflow, + ConnectorBase.download, + ConnectorBase.extend_result, + ConnectorBase.get, + ConnectorBase.get_ai_tool_definitions, + ConnectorBase.get_data, + ConnectorBase.get_tools, + ConnectorBase.get_workflow, + ConnectorBase.handle_ai_tool_call, + ConnectorBase.patch, + ConnectorBase.patch_data, + ConnectorBase.patch_workflow, + ConnectorBase.post, + ConnectorBase.post_data, + ConnectorBase.post_workflow, + ConnectorBase.put, + ConnectorBase.put_data, + ConnectorBase.put_workflow, + ConnectorBase.request, + ConnectorBase.request_data, + ConnectorBase.request_data_file, + ConnectorBase.request_workflow, + InputProvider.freeze_inputs, + InputProvider.get_input, + InputProvider.merge_inputs, + InputProvider.replace_inputs, + InputProvider.snapshot_inputs, +) + + +RAW_CONTAINER_ANNOTATIONS = {"Dict", "List", "Set", "Tuple", "dict", "list", "set", "tuple"} + + +def _annotation_includes_raw_container(annotation: ast.AST) -> bool: + """Return whether an annotation AST includes a built-in raw container type.""" + if isinstance(annotation, ast.Name): + return annotation.id in RAW_CONTAINER_ANNOTATIONS + if isinstance(annotation, ast.Attribute): + return annotation.attr in RAW_CONTAINER_ANNOTATIONS + if isinstance(annotation, ast.Subscript): + return _annotation_includes_raw_container(annotation.value) or _annotation_includes_raw_container( + annotation.slice + ) + if isinstance(annotation, ast.BinOp): + return _annotation_includes_raw_container(annotation.left) or _annotation_includes_raw_container( + annotation.right + ) + if isinstance(annotation, ast.Tuple): + return any(_annotation_includes_raw_container(item) for item in annotation.elts) + if isinstance(annotation, ast.List): + return any(_annotation_includes_raw_container(item) for item in annotation.elts) + return False + + +class _RawContainerReturnVisitor(ast.NodeVisitor): + def __init__(self, relative_path: str) -> None: + self.relative_path = relative_path + self.class_stack: list[str] = [] + self.function_depth = 0 + self.offenders: list[str] = [] + + def visit_If(self, node: ast.If) -> None: + if ast.unparse(node.test) == "TYPE_CHECKING": + return + self.generic_visit(node) + + def visit_ClassDef(self, node: ast.ClassDef) -> None: + self.class_stack.append(node.name) + self.generic_visit(node) + self.class_stack.pop() + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + self._visit_function(node) + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + self._visit_function(node) + + def _visit_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: + is_nested_function = self.function_depth > 0 + qualname = ".".join([*self.class_stack, node.name]) + + if not is_nested_function and not node.name.startswith("_") and node.returns is not None: + annotation = ast.unparse(node.returns) + if _annotation_includes_raw_container(node.returns): + boundary = (self.relative_path, qualname) + if boundary not in RAW_CONNECTOR_BOUNDARIES: + self.offenders.append(f"{self.relative_path}:{node.lineno}: {qualname} -> {annotation}") + + self.function_depth += 1 + self.generic_visit(node) + self.function_depth -= 1 + + +@pytest.mark.parametrize(("method", "expected_return"), PAYLOAD_METHODS) +def test_direct_connector_methods_advertise_extended_payloads(method: object, expected_return: object) -> None: + """Public connector data methods expose Tier 2 payload contracts.""" + return_type = get_type_hints(method)["return"] + + if get_origin(expected_return) is ExtendedList: + assert get_origin(return_type) is ExtendedList + assert get_args(return_type) == get_args(expected_return) + return + + assert return_type == expected_return + + +@pytest.mark.parametrize(("method", "expected_return"), PAYLOAD_METHODS) +def test_payload_methods_are_accepted_by_connector_data_surface(method: object, expected_return: object) -> None: + """Every annotated connector payload method should be eligible for data-surface exposure.""" + assert is_connector_data_method(method), f"{method!r} -> {expected_return!r}" + + +@pytest.mark.parametrize("method", RAW_DATA_SURFACE_METHODS) +def test_inherited_transport_and_input_helpers_are_not_data_surface_methods(method: object) -> None: + """Inherited raw helpers should stay out of CLI and MCP data surfaces.""" + assert not is_connector_data_method(method), getattr(method, "__qualname__", repr(method)) + + +def test_builtin_connector_data_surfaces_do_not_expose_raw_helpers() -> None: + """Built-in connector CLI/MCP surfaces should expose payload methods, not fabric plumbing.""" + offenders: dict[str, list[str]] = {} + empty_surfaces: list[str] = [] + + for name, spec in BUILTIN_CONNECTORS.items(): + connector_class = getattr(connector_exports, spec.class_name) + method_names = {method_name for method_name, _ in connector_data_methods(connector_class)} + leaked = sorted(method_names & RAW_DATA_SURFACE_METHOD_NAMES) + if leaked: + offenders[name] = leaked + if not method_names: + empty_surfaces.append(name) + + assert offenders == {} + assert empty_surfaces == [] + + +def test_raw_connector_container_returns_are_explicit_boundaries() -> None: + """Public connector payloads should not drift back to plain dict/list returns.""" + offenders: list[str] = [] + + for path in sorted((REPO_ROOT / "src/extended_data/connectors").rglob("*.py")): + if path.name == "tools.py": + continue + + relative_path = path.relative_to(REPO_ROOT).as_posix() + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + visitor = _RawContainerReturnVisitor(relative_path) + visitor.visit(tree) + offenders.extend(visitor.offenders) + + assert offenders == [] diff --git a/tests/connectors/test_connectors.py b/tests/connectors/test_connectors.py index 0767213..94134ae 100644 --- a/tests/connectors/test_connectors.py +++ b/tests/connectors/test_connectors.py @@ -2,14 +2,14 @@ from __future__ import annotations -from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest from extended_data.connectors import registry +from extended_data.connectors.base import ConnectorBase from extended_data.connectors.connectors import ConnectorFabric -from extended_data.connectors.registry import _register_builtins +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString # Helper to check if optional dependencies are available @@ -58,6 +58,22 @@ def test_get_cache_key(self): assert key1 == key2 assert key1 != key3 + def test_get_cache_key_hashes_sensitive_values(self): + """Sensitive cache-key fields should not expose raw credentials.""" + vc = ConnectorFabric(from_environment=False) + + key1 = vc._get_cache_key(github_token="ghp_raw_token", client_secret="zoom-secret", normal="public") + key2 = vc._get_cache_key(github_token="ghp_raw_token", client_secret="zoom-secret", normal="public") + key3 = vc._get_cache_key(github_token="ghp_other_token", client_secret="zoom-secret", normal="public") + + assert key1 == key2 + assert key1 != key3 + rendered = repr(key1) + assert "ghp_raw_token" not in rendered + assert "zoom-secret" not in rendered + assert "sha256" in rendered + assert "public" in rendered + def test_cache_client(self): """Test caching and retrieving clients.""" vc = ConnectorFabric() @@ -133,6 +149,97 @@ def __init__(self, **kwargs): assert third is not first assert mock_get_connector_class.call_count == 2 + @patch("extended_data.connectors.connectors.get_connector_class") + def test_get_connector_cache_does_not_store_raw_sensitive_kwargs(self, mock_get_connector_class): + """Generic connector caching hashes secret-like constructor arguments.""" + + class DummyConnector: + def __init__(self, **kwargs): + self.kwargs = kwargs + + vc = ConnectorFabric(from_environment=False) + mock_get_connector_class.return_value = DummyConnector + + first = vc.get_connector("dummy", api_key="key_123", password="hunter2") + second = vc.get_connector("dummy", api_key="key_123", password="hunter2") + + assert first is second + assert mock_get_connector_class.call_count == 1 + rendered_cache = repr(vc._client_cache) + assert "key_123" not in rendered_cache + assert "hunter2" not in rendered_cache + assert "sha256" in rendered_cache + + def test_connector_fabric_exposes_catalog_info(self): + """ConnectorFabric exposes registry-backed catalog metadata.""" + vc = ConnectorFabric(from_environment=False) + + info = vc.list_connector_info() + names = {connector["name"] for connector in info} + + assert isinstance(info, ExtendedList) + assert isinstance(info[0], ExtendedDict) + assert isinstance(info[0]["name"], ExtendedString) + assert "cursor" in names + assert "github" in names + github_info = vc.get_connector_info(" github ") + categories = vc.list_connector_categories() + capabilities = vc.list_connector_capabilities() + cloud_connectors = vc.list_connectors_by_category("cloud") + repository_connectors = vc.list_connectors_by_capability("repositories") + connector_names = vc.list_connectors() + available_connector_names = vc.list_available_connectors() + assert isinstance(github_info, ExtendedDict) + assert github_info["name"] == "github" + assert github_info["category"] == "development" + assert "repositories" in github_info["capabilities"] + assert isinstance(github_info["capabilities"], ExtendedList) + assert isinstance(github_info["capabilities"][0], ExtendedString) + assert isinstance(categories, ExtendedList) + assert isinstance(categories[0], ExtendedString) + assert "ai" in categories + assert "cloud" in categories + assert isinstance(capabilities, ExtendedList) + assert isinstance(capabilities[0], ExtendedString) + assert "repositories" in capabilities + assert isinstance(cloud_connectors, ExtendedList) + assert all(isinstance(connector, ExtendedDict) for connector in cloud_connectors) + assert {"aws", "google"} <= {connector["name"] for connector in cloud_connectors} + assert isinstance(repository_connectors, ExtendedList) + assert "github" in {connector["name"] for connector in repository_connectors} + assert isinstance(connector_names, ExtendedList) + assert isinstance(connector_names[0], ExtendedString) + assert "cursor" in connector_names + assert "github" in connector_names + assert isinstance(available_connector_names, ExtendedList) + assert "cursor" in available_connector_names + assert set(available_connector_names) <= set(connector_names) + assert ("github" in available_connector_names) is github_info["available"] + + def test_external_connector_metadata_uses_base_class_catalog_contract(self, monkeypatch): + """Entry-point connectors can publish category and capability metadata.""" + + class CustomConnector(ConnectorBase): + CONNECTOR_CATEGORY = "Data_Warehouse" + CONNECTOR_CAPABILITIES = ("SQL", "Files", "sql") + + monkeypatch.setattr(registry, "_connector_cache", {"custom": CustomConnector}) + monkeypatch.setattr(registry, "_missing_builtin_connectors", {}) + + info = registry.get_connector_info("custom") + categories = registry.list_connector_categories() + capabilities = registry.list_connector_capabilities() + warehouse_connectors = registry.list_connectors_by_category("data_warehouse") + sql_connectors = registry.list_connectors_by_capability("sql") + + assert info["source"] == "entry_point" + assert info["category"] == "data-warehouse" + assert info["capabilities"] == ["sql", "files"] + assert "data-warehouse" in categories + assert "sql" in capabilities + assert warehouse_connectors[0]["name"] == "custom" + assert sql_connectors[0]["name"] == "custom" + @requires_boto3 @patch("extended_data.connectors.aws.AWSConnector") def test_get_aws_connector(self, mock_aws): @@ -209,6 +316,27 @@ def test_get_google_client(self, mock_google): assert result == mock_connector + @requires_google + @patch("extended_data.connectors.google.GoogleConnector") + def test_get_google_client_cache_separates_scopes(self, mock_google): + """Google connector cache keys include requested OAuth scopes.""" + vc = ConnectorFabric( + inputs={"GOOGLE_SERVICE_ACCOUNT": '{"type": "service_account"}'}, + from_environment=False, + ) + first_connector = MagicMock() + second_connector = MagicMock() + mock_google.side_effect = [first_connector, second_connector] + + first = vc.get_google_client(scopes=["scope-a"]) + second = vc.get_google_client(scopes=["scope-b"]) + third = vc.get_google_client(scopes=["scope-a"]) + + assert first is first_connector + assert second is second_connector + assert third is first_connector + assert mock_google.call_count == 2 + @requires_github @patch("extended_data.connectors.github.GitHubConnector") def test_get_github_client(self, mock_github): @@ -323,44 +451,126 @@ def test_get_connector_class_known_missing_builtin_has_install_hint(self, monkey monkeypatch.setitem( registry._missing_builtin_connectors, "github", - ImportError("No module named 'github'"), + ImportError("No module named 'github' password=hunter2 Authorization: Bearer raw_token"), + ) + + with pytest.raises(ImportError, match=r"extended-data\[github\]") as exc_info: + registry.get_connector_class(" github ") + + message = str(exc_info.value) + assert "hunter2" not in message + assert "raw_token" not in message + assert "[REDACTED]" in message + + def test_get_connector_info_includes_known_missing_builtin(self, monkeypatch): + """Registry metadata includes unavailable known connectors.""" + monkeypatch.setattr(registry, "_connector_cache", {}) + monkeypatch.setitem( + registry._missing_builtin_connectors, + "github", + ImportError("No module named 'github' password=hunter2 Authorization: Bearer raw_token"), ) - with pytest.raises(ImportError, match=r"extended-data\[github\]"): + info = registry.get_connector_info(" github ") + + assert isinstance(info, ExtendedDict) + assert isinstance(info["name"], ExtendedString) + assert info["name"] == "github" + assert info["available"] is False + assert info["extra"] == "github" + assert info["install"] == "pip install extended-data[github]" + assert info["class"] == "GitHubConnector" + assert "hunter2" not in info["error"] + assert "raw_token" not in info["error"] + assert "[REDACTED]" in info["error"] + + def test_get_connector_class_redacts_unknown_connector_name(self, monkeypatch): + """Unknown connector diagnostics should not echo secret-bearing names.""" + monkeypatch.setattr(registry, "_connector_cache", {}) + monkeypatch.setattr(registry, "_missing_builtin_connectors", {}) + + with pytest.raises(ValueError) as exc_info: + registry.get_connector_class("password=hunter2 Authorization: Bearer raw_token") + + message = str(exc_info.value) + assert "hunter2" not in message + assert "raw_token" not in message + assert "[REDACTED]" in message + + def test_get_connector_class_rejects_unregistered_builtin_entry_point(self, monkeypatch): + """Declared built-ins must be registered through entry points.""" + monkeypatch.setattr(registry, "_connector_cache", {}) + monkeypatch.setattr(registry, "_missing_builtin_connectors", {}) + + with pytest.raises(RuntimeError, match="not registered"): registry.get_connector_class(" github ") - def test_register_builtins_tracks_missing_optional_dependency(self, monkeypatch): - """Built-in discovery remembers optional dependency import failures.""" + def test_get_connector_info_reports_unregistered_builtin_entry_point(self, monkeypatch): + """Registry metadata exposes missing built-in entry-point registration.""" + monkeypatch.setattr(registry, "_connector_cache", {}) monkeypatch.setattr(registry, "_missing_builtin_connectors", {}) - def fake_import_module(module_path): - if module_path == "extended_data.connectors.github": - raise ImportError("No module named 'github'") - return SimpleNamespace() + info = registry.get_connector_info(" github ") + + assert isinstance(info, ExtendedDict) + assert info["name"] == "github" + assert info["available"] is False + assert info["extra"] == "github" + assert "not registered" in info["error"] + + def test_builtin_with_missing_requirements_is_unavailable(self): + """Entry-point registered built-ins report unavailable when extras are missing.""" + registry.clear_cache() - monkeypatch.setattr("importlib.import_module", fake_import_module) + if not _has_module("boto3"): + info = registry.get_connector_info("aws") + + assert isinstance(info, ExtendedDict) + assert isinstance(info["missing"], ExtendedList) + assert info["available"] is False + assert info["missing"] == ["boto3"] + + with pytest.raises(ImportError, match=r"extended-data\[aws\]"): + registry.get_connector_class("aws") + + def test_available_only_catalog_filters_missing_builtins(self): + """Available-only metadata excludes built-ins with missing extras.""" + registry.clear_cache() - _register_builtins({}) + info = registry.list_connector_info(include_unavailable=False) - assert "github" in registry._missing_builtin_connectors + assert isinstance(info, ExtendedList) + assert all(connector["available"] for connector in info) - def test_register_builtins_includes_specialized_google_connectors(self): - """Registry builtins expose the advertised specialized Google connectors.""" - pytest.importorskip("googleapiclient") - connectors = {} + def test_list_connectors_reports_catalog_names_and_available_names_explicitly(self, monkeypatch): + """Connector catalog names and runtime-available names are separate APIs.""" - _register_builtins(connectors) + class CursorConnector: + pass - assert connectors["google"].__name__ == "GoogleConnector" - assert connectors["google_cloud"].__name__ == "GoogleCloudConnector" - assert connectors["google_workspace"].__name__ == "GoogleWorkspaceConnector" - assert connectors["google_billing"].__name__ == "GoogleBillingConnector" + class GitHubConnector: + pass - def test_register_builtins_loads_github_entrypoint_name(self): - """Registry builtins keep the GitHub connector spelling compatible with entry points.""" - pytest.importorskip("github") - connectors = {} + monkeypatch.setattr( + registry, + "_connector_cache", + { + "cursor": CursorConnector, + "github": GitHubConnector, + }, + ) + monkeypatch.setattr(registry, "_missing_builtin_connectors", {}) + monkeypatch.setattr( + registry, + "get_missing_connector_requirements", + lambda name: ExtendedList(["github"]) if name == "github" else ExtendedList(), + ) - _register_builtins(connectors) + catalog_names = registry.list_connectors() + available_names = registry.list_available_connectors() - assert connectors["github"].__name__ == "GitHubConnector" + assert isinstance(catalog_names, ExtendedList) + assert "cursor" in catalog_names + assert "github" in catalog_names + assert isinstance(available_names, ExtendedList) + assert available_names == ["cursor"] diff --git a/tests/connectors/test_cursor.py b/tests/connectors/test_cursor.py index 6d80b85..ddd6d85 100644 --- a/tests/connectors/test_cursor.py +++ b/tests/connectors/test_cursor.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import os from unittest.mock import MagicMock, patch @@ -13,15 +14,41 @@ AgentState, Conversation, ConversationMessage, + CursorAPIError, CursorConnector, CursorError, CursorValidationError, Repository, + sanitize_error, validate_agent_id, validate_prompt_text, validate_repository, validate_webhook_url, ) +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString, extend_data + + +def _logged_text(logger: MagicMock) -> str: + """Collect structured mock log calls into one searchable diagnostic string.""" + messages: list[str] = [] + for method_name in ("debug", "info", "warning", "error", "exception"): + method = getattr(logger, method_name) + for call in method.call_args_list: + messages.extend(str(arg) for arg in call.args) + messages.extend(str(value) for value in call.kwargs.values()) + return "\n".join(messages) + + +def _json_response(payload: object) -> MagicMock: + """Build a JSON Cursor API response mock.""" + response = MagicMock() + response.status_code = 200 + response.is_success = True + response.headers = {"content-type": "application/json"} + response.text = json.dumps(payload) + response.content = response.text.encode() + response.json.return_value = payload + return response class TestValidators: @@ -113,6 +140,40 @@ def test_validate_webhook_url_ipv6_internal(self): with pytest.raises(CursorValidationError, match="internal"): validate_webhook_url("https://[fe80::1]/webhook") + def test_sanitize_error_uses_shared_secret_redaction(self): + """Cursor error sanitization should cover common connector secret patterns.""" + redacted = sanitize_error("failed password=hunter2 token=tok_123 Authorization: Bearer raw_token") + + assert "hunter2" not in redacted + assert "tok_123" not in redacted + assert "raw_token" not in redacted + assert "[REDACTED]" in redacted + + def test_sanitize_error_redacts_explicit_values(self): + """Cursor sanitization should remove caller-provided identifiers, not just secret keys.""" + redacted = sanitize_error( + "request to /agents/secret-agent failed for secret-org/private-repo", + values=["secret-agent", "secret-org/private-repo"], + ) + + assert "secret-agent" not in redacted + assert "secret-org/private-repo" not in redacted + assert "[REDACTED]" in redacted + + def test_agent_model_payload_redacts_error(self): + """Cursor agent payload serialization should redact agent error text.""" + agent = Agent( + id="test-agent-123", + state=AgentState.ERRORED, + error="failed password=hunter2 Authorization: Bearer raw_token", + ) + + payload = CursorConnector._model_payload(agent) + + assert "hunter2" not in payload["error"] + assert "raw_token" not in payload["error"] + assert "[REDACTED]" in payload["error"] + class TestModels: """Tests for Pydantic models.""" @@ -159,6 +220,33 @@ def test_conversation_model(self): assert len(conv.messages) == 2 +class TestTransport: + """Tests for Cursor HTTP transport integration with Extended Data.""" + + @patch("extended_data.connectors.cursor.httpx.Client") + def test_request_api_decodes_response_through_extended_data_boundary(self, mock_client_class): + """Private transport should decode JSON bytes into ExtendedDict payloads.""" + mock_client = MagicMock() + mock_client_class.return_value = mock_client + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.is_success = True + mock_response.headers = {"content-type": "application/json"} + mock_response.text = '{"service": {"name": "api"}}' + mock_response.content = mock_response.text.encode() + mock_response.json.side_effect = AssertionError("raw response.json() should not be used") + mock_client.request.return_value = mock_response + + connector = CursorConnector(api_key="test-key") + payload = connector._request_api("/status") + + assert isinstance(payload, ExtendedDict) + assert isinstance(payload["service"], ExtendedDict) + assert isinstance(payload["service"]["name"], ExtendedString) + assert payload["service"]["name"].upper_first() == "Api" + + class TestCursorConnector: """Tests for CursorConnector.""" @@ -195,15 +283,94 @@ def test_list_agents(self, mock_client_class): mock_response.is_success = True mock_response.headers = {"content-type": "application/json"} mock_response.text = '{"agents": [{"id": "agent-1", "state": "running"}]}' + mock_response.content = mock_response.text.encode() mock_response.json.return_value = {"agents": [{"id": "agent-1", "state": "running"}]} mock_client.request.return_value = mock_response connector = CursorConnector(api_key="test-key") agents = connector.list_agents() + assert isinstance(agents, ExtendedList) + assert isinstance(agents[0], ExtendedDict) + assert isinstance(agents[0]["id"], ExtendedString) assert len(agents) == 1 - assert agents[0].id == "agent-1" - assert agents[0].state == AgentState.RUNNING + assert agents[0]["id"] == "agent-1" + assert agents[0]["state"] == "running" + + @patch("extended_data.connectors.cursor.httpx.Client") + def test_get_agent_status_returns_extended_dict(self, mock_client_class): + """get_agent_status should return an extended agent payload.""" + mock_client = MagicMock() + mock_client_class.return_value = mock_client + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.is_success = True + mock_response.headers = {"content-type": "application/json"} + mock_response.text = '{"id": "agent-1", "state": "finished", "pr_url": "https://github.com/org/repo/pull/1"}' + mock_response.content = mock_response.text.encode() + mock_response.json.return_value = { + "id": "agent-1", + "state": "finished", + "pr_url": "https://github.com/org/repo/pull/1", + } + mock_client.request.return_value = mock_response + + connector = CursorConnector(api_key="test-key") + agent = connector.get_agent_status("agent-1") + + assert isinstance(agent, ExtendedDict) + assert isinstance(agent["state"], ExtendedString) + assert agent["pr_url"] == "https://github.com/org/repo/pull/1" + + @patch("extended_data.connectors.cursor.httpx.Client") + def test_get_agent_status_empty_response_redacts_agent_id(self, mock_client_class): + """Empty status responses should not leak the raw agent ID in logs or errors.""" + mock_client = MagicMock() + mock_client_class.return_value = mock_client + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.is_success = True + mock_response.headers = {"content-type": "text/plain"} + mock_response.text = "" + mock_client.request.return_value = mock_response + + connector = CursorConnector(api_key="test-key") + connector.logger = MagicMock() + + with pytest.raises(CursorAPIError) as exc_info: + connector.get_agent_status("secret-agent") + + assert exc_info.value.__cause__ is None + assert "secret-agent" not in str(exc_info.value) + assert "[REDACTED]" in str(exc_info.value) + logs = _logged_text(connector.logger) + assert "secret-agent" not in logs + assert "[REDACTED]" in logs + + @patch("extended_data.connectors.cursor.httpx.Client") + def test_get_agent_conversation_returns_extended_dict(self, mock_client_class): + """get_agent_conversation should return an extended conversation payload.""" + mock_client = MagicMock() + mock_client_class.return_value = mock_client + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.is_success = True + mock_response.headers = {"content-type": "application/json"} + mock_response.text = '{"messages": [{"role": "user", "content": "hello"}]}' + mock_response.content = mock_response.text.encode() + mock_response.json.return_value = {"messages": [{"role": "user", "content": "hello"}]} + mock_client.request.return_value = mock_response + + connector = CursorConnector(api_key="test-key") + conversation = connector.get_agent_conversation("agent-1") + + assert isinstance(conversation, ExtendedDict) + assert isinstance(conversation["messages"], ExtendedList) + assert isinstance(conversation["messages"][0], ExtendedDict) + assert conversation["messages"][0]["content"] == "hello" @patch("extended_data.connectors.cursor.httpx.Client") def test_launch_agent(self, mock_client_class): @@ -215,6 +382,8 @@ def test_launch_agent(self, mock_client_class): mock_response.status_code = 200 mock_response.is_success = True mock_response.headers = {"content-type": "application/json"} + mock_response.text = '{"id": "new-agent", "state": "pending"}' + mock_response.content = mock_response.text.encode() mock_response.json.return_value = {"id": "new-agent", "state": "pending"} mock_client.request.return_value = mock_response @@ -222,10 +391,13 @@ def test_launch_agent(self, mock_client_class): agent = connector.launch_agent( prompt_text="Implement feature X", repository="owner/repo", + images=extend_data([{"data": "base64", "dimensions": {"width": 16, "height": 16}}]), ) - assert agent.id == "new-agent" - assert agent.state == AgentState.PENDING + assert isinstance(agent, ExtendedDict) + assert isinstance(agent["id"], ExtendedString) + assert agent["id"] == "new-agent" + assert agent["state"] == "pending" # Verify request was made correctly call_args = mock_client.request.call_args @@ -233,6 +405,33 @@ def test_launch_agent(self, mock_client_class): assert "/agents" in call_args.args[1] assert "prompt" in call_args.kwargs["json"] assert "source" in call_args.kwargs["json"] + assert isinstance(call_args.kwargs["json"]["prompt"]["images"], list) + assert isinstance(call_args.kwargs["json"]["prompt"]["images"][0], dict) + + @patch("extended_data.connectors.cursor.httpx.Client") + def test_launch_agent_redacts_repository_diagnostics_but_preserves_payload(self, mock_client_class): + """Agent launches should send raw repository data while redacting logs.""" + mock_client = MagicMock() + mock_client_class.return_value = mock_client + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.is_success = True + mock_response.headers = {"content-type": "application/json"} + mock_response.text = '{"id": "new-agent", "state": "pending"}' + mock_response.content = mock_response.text.encode() + mock_response.json.return_value = {"id": "new-agent", "state": "pending"} + mock_client.request.return_value = mock_response + + connector = CursorConnector(api_key="test-key") + connector.logger = MagicMock() + connector.launch_agent(prompt_text="Implement feature X", repository="secret-org/private-repo") + + call_args = mock_client.request.call_args + assert call_args.kwargs["json"]["source"]["repository"] == "secret-org/private-repo" + logs = _logged_text(connector.logger) + assert "secret-org/private-repo" not in logs + assert "[REDACTED]" in logs @patch("extended_data.connectors.cursor.httpx.Client") def test_launch_agent_validation(self, mock_client_class): @@ -246,3 +445,138 @@ def test_launch_agent_validation(self, mock_client_class): with pytest.raises(CursorValidationError, match="format"): connector.launch_agent(prompt_text="Hello", repository="invalid") + + @patch("extended_data.connectors.cursor.httpx.Client") + def test_list_repositories_returns_extended_list(self, mock_client_class): + """list_repositories should return extended repository payloads.""" + mock_client = MagicMock() + mock_client_class.return_value = mock_client + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.is_success = True + mock_response.headers = {"content-type": "application/json"} + mock_response.text = '{"repositories": [{"name": "org/repo", "default_branch": "main"}]}' + mock_response.content = mock_response.text.encode() + mock_response.json.return_value = {"repositories": [{"name": "org/repo", "default_branch": "main"}]} + mock_client.request.return_value = mock_response + + connector = CursorConnector(api_key="test-key") + repositories = connector.list_repositories() + + assert isinstance(repositories, ExtendedList) + assert isinstance(repositories[0], ExtendedDict) + assert repositories[0]["name"] == "org/repo" + + @patch("extended_data.connectors.cursor.httpx.Client") + def test_list_models_returns_extended_list(self, mock_client_class): + """list_models should expose model names as an extended container.""" + mock_client = MagicMock() + mock_client_class.return_value = mock_client + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.is_success = True + mock_response.headers = {"content-type": "application/json"} + mock_response.text = '{"models": ["cursor-small", "cursor-large"]}' + mock_response.content = mock_response.text.encode() + mock_response.json.return_value = {"models": ["cursor-small", "cursor-large"]} + mock_client.request.return_value = mock_response + + connector = CursorConnector(api_key="test-key") + models = connector.list_models() + + assert isinstance(models, ExtendedList) + assert isinstance(models[0], ExtendedString) + assert models[0].to_snake_case() == "cursor_small" + + @patch("extended_data.connectors.cursor.httpx.Client") + def test_list_models_empty_response_returns_extended_list(self, mock_client_class): + """list_models should extend the empty response path too.""" + mock_client = MagicMock() + mock_client_class.return_value = mock_client + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.is_success = True + mock_response.headers = {"content-type": "application/json"} + mock_response.text = "{}" + mock_response.content = mock_response.text.encode() + mock_response.json.return_value = {} + mock_client.request.return_value = mock_response + + connector = CursorConnector(api_key="test-key") + models = connector.list_models() + + assert isinstance(models, ExtendedList) + assert models == [] + + @pytest.mark.parametrize( + ("method_name", "call", "payload", "raw_values"), + [ + ( + "list_agents", + lambda connector: connector.list_agents(), + {"agents": [{"state": "running", "password": "hunter2", "authorization": "Bearer raw_token"}]}, + ["hunter2", "raw_token"], + ), + ( + "get_agent_status", + lambda connector: connector.get_agent_status("secret-agent"), + {"id": "secret-agent", "api_key": "key_123"}, + ["secret-agent", "key_123"], + ), + ( + "get_agent_conversation", + lambda connector: connector.get_agent_conversation("secret-agent"), + {"messages": [{"role": "user", "password": "hunter2", "authorization": "Bearer raw_token"}]}, + ["secret-agent", "hunter2", "raw_token"], + ), + ( + "launch_agent", + lambda connector: connector.launch_agent( + prompt_text="rotate password=hunter2 for customer-prod", + repository="secret-org/private-repo", + ), + {"state": "pending", "task": "rotate password=hunter2 for secret-org/private-repo"}, + ["hunter2", "secret-org/private-repo", "customer-prod"], + ), + ( + "list_repositories", + lambda connector: connector.list_repositories(), + {"repositories": [{"url": "https://github.com/org/repo", "client_secret": "secret_123"}]}, + ["secret_123"], + ), + ( + "list_models", + lambda connector: connector.list_models(), + {"models": ["cursor-small", {"password": "hunter2"}]}, + ["hunter2"], + ), + ], + ) + @patch("extended_data.connectors.cursor.httpx.Client") + def test_success_response_validation_errors_are_redacted( + self, + mock_client_class, + method_name, + call, + payload, + raw_values, + ): + """Malformed success payloads should fail loudly without raw Pydantic details.""" + mock_client = MagicMock() + mock_client_class.return_value = mock_client + mock_client.request.return_value = _json_response(payload) + + connector = CursorConnector(api_key="test-key") + + with pytest.raises(CursorAPIError) as exc_info: + call(connector) + + message = str(exc_info.value) + assert method_name in message + assert "ValidationError" not in message + assert "[REDACTED]" in message + for raw_value in raw_values: + assert raw_value not in message diff --git a/tests/connectors/test_cursor_tools.py b/tests/connectors/test_cursor_tools.py index 99c4de5..72022a2 100644 --- a/tests/connectors/test_cursor_tools.py +++ b/tests/connectors/test_cursor_tools.py @@ -4,6 +4,9 @@ from unittest.mock import MagicMock, patch +from extended_data.connectors.cursor import AgentState +from extended_data.containers import ExtendedDict, ExtendedString, extend_data + def test_cursor_launch_agent(): """Test launch_agent tool.""" @@ -11,16 +14,22 @@ def test_cursor_launch_agent(): with patch("extended_data.connectors.cursor.CursorConnector") as mock_connector_class: mock_connector = MagicMock() - mock_agent = MagicMock() - mock_agent.id = "agent_123" - mock_agent.state = "running" - mock_agent.repository = "org/repo" + mock_agent = extend_data( + { + "id": "agent_123", + "state": AgentState.RUNNING, + "repository": "org/repo", + } + ) mock_connector.launch_agent.return_value = mock_agent mock_connector_class.return_value = mock_connector result = cursor_launch_agent(prompt="Fix bug", repository="org/repo") + assert isinstance(result, ExtendedDict) + assert isinstance(result["agent_id"], ExtendedString) assert result["agent_id"] == "agent_123" assert result["state"] == "running" + assert result["repository"].sanitize() == "org_repo" def test_cursor_get_agent_status(): @@ -29,15 +38,45 @@ def test_cursor_get_agent_status(): with patch("extended_data.connectors.cursor.CursorConnector") as mock_connector_class: mock_connector = MagicMock() - mock_agent = MagicMock() - mock_agent.id = "agent_123" - mock_agent.state = "finished" - mock_agent.error = None - mock_agent.pr_url = "https://github.com/org/repo/pull/1" + mock_agent = extend_data( + { + "id": "agent_123", + "state": AgentState.FINISHED, + "error": None, + "pr_url": "https://github.com/org/repo/pull/1", + } + ) mock_connector.get_agent_status.return_value = mock_agent mock_connector_class.return_value = mock_connector result = cursor_get_agent_status(agent_id="agent_123") + assert isinstance(result, ExtendedDict) + assert isinstance(result["state"], ExtendedString) assert result["agent_id"] == "agent_123" assert result["state"] == "finished" assert result["pr_url"] == "https://github.com/org/repo/pull/1" + + +def test_cursor_get_agent_status_redacts_error(): + """Cursor status tool should not expose secret-bearing agent errors.""" + from extended_data.connectors.cursor.tools import cursor_get_agent_status + + with patch("extended_data.connectors.cursor.CursorConnector") as mock_connector_class: + mock_connector = MagicMock() + mock_agent = extend_data( + { + "id": "agent_123", + "state": AgentState.ERRORED, + "error": "failed password=hunter2 Authorization: Bearer raw_token", + "pr_url": None, + } + ) + mock_connector.get_agent_status.return_value = mock_agent + mock_connector_class.return_value = mock_connector + + result = cursor_get_agent_status(agent_id="agent_123") + + assert isinstance(result, ExtendedDict) + assert "hunter2" not in result["error"] + assert "raw_token" not in result["error"] + assert "[REDACTED]" in result["error"] diff --git a/tests/connectors/test_github_connector.py b/tests/connectors/test_github_connector.py index a75fb08..df8e036 100644 --- a/tests/connectors/test_github_connector.py +++ b/tests/connectors/test_github_connector.py @@ -1,17 +1,23 @@ -"""Tests for GitHub connector aliases and behavior.""" +# ruff: noqa: I001 +"""Tests for GitHub connector exports and behavior.""" from __future__ import annotations from unittest.mock import MagicMock, patch +import pytest + +pytest.importorskip("github") + from extended_data.connectors import GitHubConnector as RootGitHubConnector from extended_data.connectors.github import GitHubConnector +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString class TestGitHubConnector: """Test suite for GitHub connector behavior.""" - def test_root_export_alias_points_to_same_connector(self): + def test_root_export_points_to_same_connector(self): """The canonical root export and canonical class should resolve to the same class.""" assert RootGitHubConnector is GitHubConnector @@ -91,4 +97,90 @@ def test_get_repository_file(self, mock_github_class, base_connector_kwargs): ) content = connector.get_repository_file("test.json") - assert content is not None + assert isinstance(content, ExtendedDict) + assert isinstance(content["test"], ExtendedString) + assert content["test"].upper_first() == "Data" + + @patch("extended_data.connectors.github.Github") + def test_get_repository_file_with_metadata_returns_extended_tuple(self, mock_github_class, base_connector_kwargs): + """Repository file metadata tuples preserve shape while promoting decoded content.""" + mock_github = MagicMock() + mock_org = MagicMock() + mock_repo = MagicMock() + mock_file = MagicMock() + mock_file.decoded_content = b'{"test": "data"}' + mock_file.sha = "abc123" + mock_file.content = "test content" + + mock_repo.get_contents.return_value = mock_file + mock_repo.default_branch = "main" + mock_github.get_organization.return_value = mock_org + mock_github.get_repo.return_value = mock_repo + mock_github_class.return_value = mock_github + + connector = GitHubConnector( + github_owner="test-org", github_repo="test-repo", github_token="test-token", **base_connector_kwargs + ) + + content, sha, path = connector.get_repository_file("test.json", return_sha=True, return_path=True) + + assert isinstance(content, ExtendedDict) + assert isinstance(content["test"], ExtendedString) + assert sha == "abc123" + assert path == "test.json" + + @patch("extended_data.connectors.github.Github") + def test_list_repositories_promotes_vendor_payloads(self, mock_github_class, base_connector_kwargs): + """Vendor SDK list payloads should return extended containers.""" + mock_github = MagicMock() + mock_org = MagicMock() + mock_repo = MagicMock() + mock_repo.id = 1 + mock_repo.name = "api-service" + mock_repo.full_name = "test-org/api-service" + mock_repo.description = "API service" + mock_repo.private = False + mock_repo.archived = False + mock_repo.default_branch = "main" + mock_repo.html_url = "https://github.com/test-org/api-service" + mock_repo.clone_url = "https://github.com/test-org/api-service.git" + mock_repo.ssh_url = "git@github.com:test-org/api-service.git" + mock_repo.language = "Python" + mock_repo.topics = ["data", "vendor"] + mock_repo.created_at = None + mock_repo.updated_at = None + mock_repo.pushed_at = None + + mock_org.get_repos.return_value = [mock_repo] + mock_github.get_organization.return_value = mock_org + mock_github_class.return_value = mock_github + + connector = GitHubConnector(github_owner="test-org", github_token="test-token", **base_connector_kwargs) + + repos = connector.list_repositories() + + assert isinstance(repos, ExtendedDict) + assert isinstance(repos["api-service"], ExtendedDict) + assert isinstance(repos["api-service"]["name"], ExtendedString) + assert isinstance(repos["api-service"]["topics"], ExtendedList) + assert repos["api-service"]["name"].to_snake_case() == "api_service" + + @patch("extended_data.connectors.github.Github") + def test_build_workflow_helpers_return_extended_data(self, mock_github_class, base_connector_kwargs): + """GitHub workflow builders should also produce first-class extended data.""" + mock_github = MagicMock() + mock_org = MagicMock() + mock_github.get_organization.return_value = mock_org + mock_github_class.return_value = mock_github + + connector = GitHubConnector(github_owner="test-org", github_token="test-token", **base_connector_kwargs) + + step = connector.build_workflow_step(name="Run tests", run="pytest") + job = connector.build_workflow_job(steps=[step]) + workflow = connector.build_workflow(name="CI", on={"pull_request": {}}, jobs={"test": job}) + + assert isinstance(step, ExtendedDict) + assert isinstance(job, ExtendedDict) + assert isinstance(workflow, ExtendedDict) + assert isinstance(workflow["jobs"]["test"]["steps"], ExtendedList) + assert workflow["jobs"]["test"]["steps"][0]["run"].upper_first() == "Pytest" diff --git a/tests/connectors/test_github_payload_contract.py b/tests/connectors/test_github_payload_contract.py new file mode 100644 index 0000000..ab1c5cb --- /dev/null +++ b/tests/connectors/test_github_payload_contract.py @@ -0,0 +1,789 @@ +"""Dependency-free GitHub connector payload contract tests.""" + +from __future__ import annotations + +from unittest.mock import MagicMock +from unittest.mock import call as mock_call + +import pytest + +import extended_data.connectors.github as github_module + +from extended_data.connectors.github import GitHubConnector, GitHubFallbackError, build_github_actions_workflow +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString, ExtendedTuple + + +def _connector() -> GitHubConnector: + """Build a GitHubConnector shell without importing optional SDK dependencies.""" + connector = GitHubConnector.__new__(GitHubConnector) + connector.GITHUB_OWNER = "test-org" + connector.GITHUB_TOKEN = "test-token" + connector.GITHUB_BRANCH = "main" + connector.logger = MagicMock() + connector.repo = MagicMock() + connector.org = MagicMock() + connector.git = MagicMock() + connector.graphql_client = MagicMock() + return connector + + +def _logged_text(logger: MagicMock) -> str: + """Collect structured mock log calls into one searchable diagnostic string.""" + messages: list[str] = [] + for method_name in ("debug", "info", "warning", "error", "exception"): + method = getattr(logger, method_name) + for log_call in method.call_args_list: + messages.extend(str(arg) for arg in log_call.args) + messages.extend(str(value) for value in log_call.kwargs.values()) + return "\n".join(messages) + + +def _member(login: str, *, member_id: int = 1) -> MagicMock: + member = MagicMock() + member.id = member_id + member.login = login + member.name = login.title() + member.email = f"{login}@example.com" + member.avatar_url = f"https://github.com/{login}.png" + member.html_url = f"https://github.com/{login}" + return member + + +def _repo(name: str) -> MagicMock: + repo = MagicMock() + repo.id = 1 + repo.name = name + repo.full_name = f"test-org/{name}" + repo.description = f"{name} repository" + repo.private = False + repo.archived = False + repo.default_branch = "main" + repo.html_url = f"https://github.com/test-org/{name}" + repo.clone_url = f"https://github.com/test-org/{name}.git" + repo.ssh_url = f"git@github.com:test-org/{name}.git" + repo.language = "Python" + repo.topics = ["data", "connector"] + repo.created_at = None + repo.updated_at = None + repo.pushed_at = None + return repo + + +def _team(slug: str) -> MagicMock: + team = MagicMock() + team.id = 1 + team.name = slug.replace("-", " ").title() + team.slug = slug + team.description = f"{slug} team" + team.privacy = "closed" + team.permission = "push" + team.html_url = f"https://github.com/orgs/test-org/teams/{slug}" + team.members_count = 1 + team.repos_count = 1 + return team + + +def test_repository_file_decodes_into_extended_payload_with_metadata() -> None: + """Decoded repository files should enter the Tier 2 fabric immediately.""" + connector = _connector() + mock_file = MagicMock() + mock_file.decoded_content = b'{"service":{"name":"api"}}' + mock_file.sha = "abc123" + mock_file.content = "test content" + connector.repo.get_contents.return_value = mock_file + + result = connector.get_repository_file("service.json", return_sha=True, return_path=True) + + assert isinstance(result, ExtendedTuple) + assert isinstance(result[0], ExtendedDict) + assert isinstance(result[0]["service"]["name"], ExtendedString) + assert result[0]["service"]["name"].upper_first() == "Api" + assert result[1:] == ("abc123", "service.json") + + +def test_get_repository_file_returns_raw_text_when_decode_disabled() -> None: + """Raw repository reads should preserve text content and optional metadata.""" + connector = _connector() + mock_file = MagicMock() + mock_file.decoded_content = b"raw text" + mock_file.sha = "abc123" + mock_file.content = "raw text" + connector.repo.get_contents.return_value = mock_file + + result = connector.get_repository_file("README.md", decode=False, return_sha=True) + + assert isinstance(result, ExtendedTuple) + assert result == ("raw text", "abc123") + connector.repo.get_contents.assert_called_once_with("README.md", ref="main") + + +def test_get_repository_file_missing_can_raise_redacted_not_found(monkeypatch: pytest.MonkeyPatch) -> None: + """Missing repository files should raise on demand without leaking caller paths.""" + monkeypatch.setattr(github_module, "UnknownObjectException", GitHubFallbackError) + connector = _connector() + connector.repo.get_contents.side_effect = GitHubFallbackError("missing private/path.json") + + with pytest.raises(FileNotFoundError) as exc_info: + connector.get_repository_file("private/path.json", raise_on_not_found=True) + + message = str(exc_info.value) + logs = _logged_text(connector.logger) + assert "[REDACTED]" in message + assert "[REDACTED]" in logs + assert "private/path.json" not in message + assert "private/path.json" not in logs + + +def test_get_repository_file_without_repo_returns_none_and_redacts_log() -> None: + """Repository file reads should no-op when no repository is configured.""" + connector = _connector() + connector.repo = None + + assert connector.get_repository_file("private/path.json") is None + + logs = _logged_text(connector.logger) + assert "[REDACTED]" in logs + assert "private/path.json" not in logs + + +def test_get_repository_file_empty_content_returns_default_payload() -> None: + """Empty repository files should return the default decoded payload shape.""" + connector = _connector() + mock_file = MagicMock() + mock_file.content = "" + mock_file.decoded_content = b"" + mock_file.sha = "empty-sha" + connector.repo.get_contents.return_value = mock_file + + result = connector.get_repository_file("empty.json", return_sha=True) + + assert isinstance(result, ExtendedTuple) + assert result == ({}, "empty-sha") + + +def test_get_repository_file_decode_failure_returns_raw_text_with_redacted_log() -> None: + """Decode failures should return raw text and avoid leaking repository paths.""" + connector = _connector() + mock_file = MagicMock() + mock_file.content = "not-json" + mock_file.decoded_content = b"{not-json" + mock_file.sha = "bad-json-sha" + connector.repo.get_contents.return_value = mock_file + + result = connector.get_repository_file("private/config.json") + + assert isinstance(result, ExtendedString) + assert result == "{not-json" + logs = _logged_text(connector.logger) + assert "[REDACTED]" in logs + assert "private/config.json" not in logs + + +def test_get_repository_file_unsupported_read_returns_raw_default() -> None: + """Unsupported SDK content reads should return decoded defaults instead of crashing.""" + connector = _connector() + mock_file = MagicMock() + mock_file.content = "content" + mock_file.decoded_content.decode.side_effect = ValueError("unsupported private/path.bin") + mock_file.sha = "binary-sha" + connector.repo.get_contents.return_value = mock_file + + result = connector.get_repository_file("private/path.bin", return_sha=True) + + assert isinstance(result, ExtendedTuple) + assert result == ({}, "binary-sha") + logs = _logged_text(connector.logger) + assert "[REDACTED]" in logs + assert "private/path.bin" not in logs + + +def test_update_repository_file_creates_missing_file_with_encoded_payload() -> None: + """Repository updates should create files when no current SHA can be found.""" + connector = _connector() + connector.get_repository_file = MagicMock(return_value=ExtendedString("")) + + result = connector.update_repository_file( + "config/service.json", + {"service": {"name": "api"}}, + allow_encoding="json", + ) + + assert result is connector.repo.create_file.return_value + connector.repo.create_file.assert_called_once() + kwargs = connector.repo.create_file.call_args.kwargs + assert kwargs["path"] == "config/service.json" + assert kwargs["message"] == "Creating config/service.json" + assert kwargs["branch"] == "main" + assert '"service"' in kwargs["content"] + connector.repo.update_file.assert_not_called() + + +def test_update_repository_file_rejects_empty_payloads_unless_allowed() -> None: + """Repository updates should not silently write empty payloads by default.""" + connector = _connector() + + result = connector.update_repository_file("empty.txt", "") + + assert result is None + connector.repo.create_file.assert_not_called() + connector.repo.update_file.assert_not_called() + logs = _logged_text(connector.logger) + assert "[REDACTED]" in logs + assert "empty.txt" not in logs + + +def test_update_repository_file_allows_empty_payloads_when_requested() -> None: + """Explicit empty writes should be allowed when allow_empty is true.""" + connector = _connector() + + result = connector.update_repository_file("empty.txt", "", file_sha="abc123", allow_empty=True, allow_encoding=False) + + assert result is connector.repo.update_file.return_value + connector.repo.update_file.assert_called_once_with( + path="empty.txt", + message="Updating empty.txt", + content="", + sha="abc123", + branch="main", + ) + + +def test_update_repository_file_without_repo_returns_none_and_redacts_log() -> None: + """Repository file updates should no-op when no repository is configured.""" + connector = _connector() + connector.repo = None + + assert connector.update_repository_file("private/path.json", {"x": 1}) is None + + logs = _logged_text(connector.logger) + assert "[REDACTED]" in logs + assert "private/path.json" not in logs + + +def test_delete_repository_file_deletes_when_sha_exists() -> None: + """Repository deletes should fetch the current SHA and call delete_file.""" + connector = _connector() + connector.get_repository_file = MagicMock(return_value=ExtendedTuple(("", "abc123"))) + + result = connector.delete_repository_file("config/service.json") + + assert result is connector.repo.delete_file.return_value + connector.get_repository_file.assert_called_once_with(file_path="config/service.json", return_sha=True) + connector.repo.delete_file.assert_called_once_with( + path="config/service.json", + message="Deleting config/service.json", + branch="main", + sha="abc123", + ) + + +def test_delete_repository_file_skips_when_sha_missing() -> None: + """Repository deletes should be no-ops when the current file cannot be resolved.""" + connector = _connector() + connector.get_repository_file = MagicMock(return_value=None) + + assert connector.delete_repository_file("missing.txt") is None + + connector.repo.delete_file.assert_not_called() + + +def test_delete_repository_file_without_repo_returns_none_and_redacts_log() -> None: + """Repository file deletes should no-op when no repository is configured.""" + connector = _connector() + connector.repo = None + + assert connector.delete_repository_file("private/path.json") is None + + logs = _logged_text(connector.logger) + assert "[REDACTED]" in logs + assert "private/path.json" not in logs + + +def test_list_repositories_promotes_sdk_payloads() -> None: + """Repository listing payloads should be extended containers, not raw dicts.""" + connector = _connector() + repo = MagicMock() + repo.id = 1 + repo.name = "api-service" + repo.full_name = "test-org/api-service" + repo.description = "API service" + repo.private = False + repo.archived = False + repo.default_branch = "main" + repo.html_url = "https://github.com/test-org/api-service" + repo.clone_url = "https://github.com/test-org/api-service.git" + repo.ssh_url = "git@github.com:test-org/api-service.git" + repo.language = "Python" + repo.topics = ["data", "vendor"] + repo.created_at = None + repo.updated_at = None + repo.pushed_at = None + connector.org.get_repos.return_value = [repo] + + result = connector.list_repositories() + + assert isinstance(result, ExtendedDict) + assert isinstance(result["api-service"], ExtendedDict) + assert isinstance(result["api-service"]["topics"], ExtendedList) + assert result["api-service"]["name"].to_snake_case() == "api_service" + + +def test_create_repository_branch_uses_parent_sha() -> None: + """Branch creation should create Git refs from the selected parent branch SHA.""" + connector = _connector() + connector.repo.default_branch = "main" + parent = MagicMock() + parent.commit.sha = "parent-sha" + connector.get_repository_branch = MagicMock(return_value=parent) + + result = connector.create_repository_branch("feature/data") + + assert result is connector.repo.create_git_ref.return_value + connector.get_repository_branch.assert_called_once_with("main") + connector.repo.create_git_ref.assert_called_once_with(ref="refs/heads/feature/data", sha="parent-sha") + + +def test_get_repository_branch_without_repo_returns_none_and_redacts_log() -> None: + """Branch lookup should no-op when no repository is configured.""" + connector = _connector() + connector.repo = None + + assert connector.get_repository_branch("private-branch") is None + + logs = _logged_text(connector.logger) + assert "[REDACTED]" in logs + assert "private-branch" not in logs + + +def test_get_repository_branch_missing_returns_none(monkeypatch: pytest.MonkeyPatch) -> None: + """Missing branch lookup should return None and redact branch names.""" + monkeypatch.setattr(github_module, "UnknownObjectException", GitHubFallbackError) + connector = _connector() + connector.repo.get_branch.side_effect = GitHubFallbackError("missing private-branch") + + assert connector.get_repository_branch("private-branch") is None + + logs = _logged_text(connector.logger) + assert "[REDACTED]" in logs + assert "private-branch" not in logs + + +def test_create_repository_branch_without_repo_returns_none_and_redacts_log() -> None: + """Branch creation should no-op when no repository is configured.""" + connector = _connector() + connector.repo = None + + assert connector.create_repository_branch("private-branch") is None + + logs = _logged_text(connector.logger) + assert "[REDACTED]" in logs + assert "private-branch" not in logs + + +def test_create_repository_branch_returns_existing_branch_on_reference_exists(monkeypatch: pytest.MonkeyPatch) -> None: + """Existing branch creation should return the current branch instead of failing.""" + + class ReferenceExistsError(GitHubFallbackError): + data = {"message": "Reference already exists"} + + monkeypatch.setattr(github_module, "GithubException", GitHubFallbackError) + connector = _connector() + parent = MagicMock() + parent.commit.sha = "parent-sha" + existing = MagicMock() + connector.repo.default_branch = "main" + connector.repo.create_git_ref.side_effect = ReferenceExistsError("Reference already exists") + connector.get_repository_branch = MagicMock(side_effect=[parent, existing]) + + assert connector.create_repository_branch("feature/data") is existing + + assert connector.get_repository_branch.call_args_list == [mock_call("main"), mock_call("feature/data")] + + +def test_create_repository_branch_redacts_unexpected_errors(monkeypatch: pytest.MonkeyPatch) -> None: + """Unexpected branch creation errors should redact branch identifiers.""" + monkeypatch.setattr(github_module, "GithubException", GitHubFallbackError) + connector = _connector() + parent = MagicMock() + parent.commit.sha = "parent-sha" + connector.get_repository_branch = MagicMock(return_value=parent) + connector.repo.create_git_ref.side_effect = GitHubFallbackError("branch private-branch token=raw-token") + + with pytest.raises(RuntimeError) as exc_info: + connector.create_repository_branch("private-branch") + + message = str(exc_info.value) + assert "[REDACTED]" in message + assert "private-branch" not in message + assert "raw-token" not in message + + +def test_create_repository_branch_raises_when_parent_missing() -> None: + """Branch creation should fail loudly when the parent branch is missing.""" + connector = _connector() + connector.get_repository_branch = MagicMock(return_value=None) + + with pytest.raises(RuntimeError, match="parent branch"): + connector.create_repository_branch("feature/data", parent_branch="missing") + + +def test_list_org_members_includes_pending_invitations() -> None: + """Organization member lists should include active and pending members when requested.""" + connector = _connector() + active = _member("octocat", member_id=1) + membership = MagicMock() + membership.role = "admin" + membership.state = "active" + invite = MagicMock() + invite.id = 2 + invite.login = None + invite.email = "pending@example.com" + invite.role = "direct_member" + invite.created_at = None + connector.org.get_members.return_value = [active] + connector.org.get_user_membership.return_value = membership + connector.org.invitations.return_value = [invite] + + result = connector.list_org_members(role="admin", include_pending=True) + + assert isinstance(result, ExtendedDict) + assert result["octocat"]["role"] == "admin" + assert result["pending@example.com"]["state"] == "pending" + connector.org.get_members.assert_called_once_with(role="admin") + + +def test_get_org_member_returns_none_for_missing_user(monkeypatch: pytest.MonkeyPatch) -> None: + """Missing organization members should return None and redact diagnostics.""" + monkeypatch.setattr(github_module, "UnknownObjectException", GitHubFallbackError) + connector = _connector() + connector.git.get_user.side_effect = GitHubFallbackError("missing secret-user") + + assert connector.get_org_member("secret-user") is None + + logs = _logged_text(connector.logger) + assert "[REDACTED]" in logs + assert "secret-user" not in logs + + +def test_list_repositories_includes_branch_payloads() -> None: + """Repository listings should optionally include promoted branch metadata.""" + connector = _connector() + repo = _repo("api-service") + branch = MagicMock() + branch.name = "main" + branch.protected = True + branch.commit.sha = "branch-sha" + repo.get_branches.return_value = [branch] + connector.org.get_repos.return_value = [repo] + + result = connector.list_repositories(type_filter="private", include_branches=True) + + assert isinstance(result["api-service"]["branches"], ExtendedList) + assert result["api-service"]["branches"][0]["name"] == "main" + assert result["api-service"]["branches"][0]["protected"] is True + assert result["api-service"]["branches"][0]["sha"] == "branch-sha" + connector.org.get_repos.assert_called_once_with(type="private") + + +def test_get_repository_returns_none_for_missing_repo(monkeypatch: pytest.MonkeyPatch) -> None: + """Missing repositories should return None and redact repo names.""" + monkeypatch.setattr(github_module, "UnknownObjectException", GitHubFallbackError) + connector = _connector() + connector.git.get_repo.side_effect = GitHubFallbackError("missing private-repo") + + assert connector.get_repository("private-repo") is None + + logs = _logged_text(connector.logger) + assert "[REDACTED]" in logs + assert "private-repo" not in logs + + +def test_list_teams_includes_members_and_repositories() -> None: + """Team lists should optionally include promoted member and repository details.""" + connector = _connector() + team = _team("data-team") + member = _member("octocat") + repo = _repo("api-service") + team.get_members.return_value = [member] + team.get_repos.return_value = [repo] + team.get_repo_permission.return_value = "admin" + connector.org.get_teams.return_value = [team] + + result = connector.list_teams(include_members=True, include_repos=True) + + assert isinstance(result, ExtendedDict) + assert result["data-team"]["members"][0]["login"] == "octocat" + assert result["data-team"]["repositories"][0]["permission"] == "admin" + assert isinstance(result["data-team"]["repositories"], ExtendedList) + + +def test_get_team_returns_promoted_payload() -> None: + """Team lookup should promote SDK payloads into Tier 2 containers.""" + connector = _connector() + connector.org.get_team_by_slug.return_value = _team("data-team") + + result = connector.get_team("data-team") + + assert isinstance(result, ExtendedDict) + assert result["slug"] == "data-team" + assert isinstance(result["name"], ExtendedString) + + +def test_get_team_returns_none_for_missing_team(monkeypatch: pytest.MonkeyPatch) -> None: + """Missing team lookups should return None and redact team slugs.""" + monkeypatch.setattr(github_module, "UnknownObjectException", GitHubFallbackError) + connector = _connector() + connector.org.get_team_by_slug.side_effect = GitHubFallbackError("missing private-team") + + assert connector.get_team("private-team") is None + + logs = _logged_text(connector.logger) + assert "[REDACTED]" in logs + assert "private-team" not in logs + + +def test_add_and_remove_team_member_success_paths() -> None: + """Team membership helpers should call the SDK and return true on success.""" + connector = _connector() + team = _team("data-team") + user = _member("octocat") + connector.org.get_team_by_slug.return_value = team + connector.git.get_user.return_value = user + + assert connector.add_team_member("data-team", "octocat", role="maintainer") is True + assert connector.remove_team_member("data-team", "octocat") is True + + team.add_membership.assert_called_once_with(user, role="maintainer") + team.remove_membership.assert_called_once_with(user) + + +def test_remove_team_member_failure_redacts_diagnostics(monkeypatch: pytest.MonkeyPatch) -> None: + """Team member removal failures should redact user/team identifiers.""" + monkeypatch.setattr(github_module, "GithubException", GitHubFallbackError) + monkeypatch.setattr(github_module, "UnknownObjectException", GitHubFallbackError) + connector = _connector() + connector.git.get_user.side_effect = GitHubFallbackError("team private-team user secret-user token=raw-token") + + assert connector.remove_team_member("private-team", "secret-user") is False + + logs = _logged_text(connector.logger) + assert "[REDACTED]" in logs + assert "private-team" not in logs + assert "secret-user" not in logs + assert "raw-token" not in logs + + +def test_execute_graphql_promotes_response_payload() -> None: + """GraphQL response dictionaries should expose nested extended containers.""" + connector = _connector() + connector.graphql_client.execute.return_value = { + "data": {"user": {"login": "octocat", "organizationVerifiedDomainEmails": ["octo@example.com"]}} + } + + result = connector.execute_graphql("query($login: String!) { user(login: $login) { login } }", {"login": "octocat"}) + + assert isinstance(result, ExtendedDict) + assert isinstance(result["data"]["user"], ExtendedDict) + assert isinstance(result["data"]["user"]["organizationVerifiedDomainEmails"], ExtendedList) + assert result["data"]["user"]["login"].upper_first() == "Octocat" + + +def test_verified_email_enrichment_returns_extended_payload() -> None: + """Derived GitHub user payloads should remain in the extended container layer.""" + connector = _connector() + connector.graphql_client.execute.return_value = { + "data": { + "user": { + "login": "octocat", + "email": "octocat@example.com", + "organizationVerifiedDomainEmails": ["octocat@example.com"], + } + } + } + + result = connector.get_users_with_verified_emails( + members={"octocat": {"login": "octocat", "role": "member"}}, + domain_filter="example.com", + ) + + assert isinstance(result, ExtendedDict) + assert isinstance(result["octocat"], ExtendedDict) + assert isinstance(result["octocat"]["verified_emails"], ExtendedList) + assert result["octocat"]["primary_email"].upper_first() == "Octocat@example.com" + + +def test_verified_email_enrichment_filters_domain_matches() -> None: + """Verified email enrichment should keep only members with matching domain emails.""" + connector = _connector() + connector.execute_graphql = MagicMock( + side_effect=[ + { + "data": { + "user": { + "email": "octocat@example.com", + "organizationVerifiedDomainEmails": ["octocat@example.com", "octocat@other.test"], + } + } + }, + { + "data": { + "user": { + "email": "nomatch@other.test", + "organizationVerifiedDomainEmails": ["nomatch@other.test"], + } + } + }, + ] + ) + + result = connector.get_users_with_verified_emails( + members={ + "octocat": {"login": "octocat"}, + "nomatch": {"login": "nomatch"}, + }, + domain_filter="example.com", + ) + + assert set(result) == {"octocat"} + assert result["octocat"]["domain_emails"] == ["octocat@example.com"] + + +def test_verified_email_enrichment_preserves_member_on_graphql_failure() -> None: + """GraphQL failures should preserve existing member payloads and redact diagnostics.""" + connector = _connector() + connector.execute_graphql = MagicMock(side_effect=RuntimeError("failed for secret-user token=raw-token")) + + result = connector.get_users_with_verified_emails(members={"secret-user": {"login": "secret-user"}}) + + assert result["secret-user"]["login"] == "secret-user" + logs = _logged_text(connector.logger) + assert "[REDACTED]" in logs + assert "secret-user" not in logs + assert "raw-token" not in logs + + +def test_workflow_builders_return_extended_data() -> None: + """Local GitHub workflow builders should produce first-class extended data.""" + connector = _connector() + + step = connector.build_workflow_step(name="Run tests", run="pytest") + job = connector.build_workflow_job(steps=[step]) + workflow = connector.build_workflow(name="CI", on={"pull_request": {}}, jobs={"test": job}) + + assert isinstance(step, ExtendedDict) + assert isinstance(job, ExtendedDict) + assert isinstance(workflow, ExtendedDict) + assert isinstance(workflow["jobs"]["test"]["steps"], ExtendedList) + assert workflow["jobs"]["test"]["steps"][0]["run"].upper_first() == "Pytest" + + +def test_create_python_ci_workflow_builds_integrated_default_pipeline() -> None: + """Python CI workflow helper should compose checkout, setup, lint, format, and test steps.""" + connector = _connector() + + workflow = connector.create_python_ci_workflow(python_versions=["3.12", "3.13"], working_directory="packages/api") + + assert isinstance(workflow, ExtendedDict) + assert workflow["name"] == "CI" + steps = workflow["jobs"]["test"]["steps"] + assert [step["name"] for step in steps] == [ + "Checkout code", + "Set up Python", + "Install uv", + "Install dependencies", + "Lint", + "Format check", + "Run tests", + ] + assert workflow["jobs"]["test"]["strategy"]["matrix"]["python-version"] == ["3.12", "3.13"] + assert steps[-1]["working-directory"] == "packages/api" + + +def test_create_python_ci_workflow_can_skip_optional_checks() -> None: + """Python CI workflow helper should omit lint/format steps when callers disable them.""" + connector = _connector() + + workflow = connector.create_python_ci_workflow(lint_command="", format_command=None) + + assert [step["name"] for step in workflow["jobs"]["test"]["steps"]] == [ + "Checkout code", + "Set up Python", + "Install uv", + "Install dependencies", + "Run tests", + ] + + +def test_build_github_actions_workflow_rejects_missing_required_fields() -> None: + """Standalone workflow YAML builder should fail loudly for unusable inputs.""" + with pytest.raises(ValueError, match="workflow_name is required"): + build_github_actions_workflow("", {"test": {"runs-on": "ubuntu-latest", "steps": []}}) + + with pytest.raises(ValueError, match="jobs definition is required"): + build_github_actions_workflow("CI", {}) + + +def test_build_github_actions_workflow_can_disable_oidc_and_events() -> None: + """Standalone workflow builder should honor event and permission options.""" + workflow_yaml = build_github_actions_workflow( + "Release", + {"release": {"runs-on": "ubuntu-latest", "steps": [{"run": "echo release"}]}}, + use_oidc_auth=False, + events={"push": False, "pull_request": False, "workflow_dispatch": True}, + triggers={"branches": ["main"]}, + pull_requests={"branches": ["main"]}, + ) + + assert "id-token" not in workflow_yaml + assert "workflow_dispatch:" in workflow_yaml + assert "pull_request:" not in workflow_yaml + + +def test_update_repository_file_redacts_diagnostics_but_preserves_payload() -> None: + """GitHub file updates should not leak caller paths or messages in logs.""" + connector = _connector() + raw_path = "private/path.txt" + raw_message = "commit mentions private/path.txt token=raw-token" + + connector.update_repository_file( + raw_path, + "raw file data", + file_sha="abc123", + msg=raw_message, + allow_encoding=False, + ) + + connector.repo.update_file.assert_called_once_with( + path=raw_path, + message=raw_message, + content="raw file data", + sha="abc123", + branch="main", + ) + logs = _logged_text(connector.logger) + assert "[REDACTED]" in logs + assert raw_path not in logs + assert raw_message not in logs + assert "raw-token" not in logs + + +def test_add_team_member_failure_redacts_diagnostics_without_traceback(monkeypatch: pytest.MonkeyPatch) -> None: + """Team membership failures should redact user/team identifiers and avoid tracebacks.""" + monkeypatch.setattr(github_module, "GithubException", GitHubFallbackError) + monkeypatch.setattr(github_module, "UnknownObjectException", GitHubFallbackError) + + connector = _connector() + connector.org.get_team_by_slug.side_effect = GitHubFallbackError( + "team private-team user secret-user token=raw-token" + ) + + assert connector.add_team_member("private-team", "secret-user") is False + + logs = _logged_text(connector.logger) + assert "[REDACTED]" in logs + assert "private-team" not in logs + assert "secret-user" not in logs + assert "raw-token" not in logs + connector.logger.exception.assert_not_called() + for log_call in connector.logger.error.call_args_list: + assert log_call.kwargs.get("exc_info") is not True diff --git a/tests/connectors/test_github_tools.py b/tests/connectors/test_github_tools.py index 15a4fd4..e7b4cfc 100644 --- a/tests/connectors/test_github_tools.py +++ b/tests/connectors/test_github_tools.py @@ -2,15 +2,30 @@ from __future__ import annotations +import importlib.util + from unittest.mock import MagicMock, patch import pytest +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString + # Patch target for GitHubConnector - patch at source since tools.py imports lazily inside functions GITHUB_CONNECTOR_PATCH = "extended_data.connectors.github.GitHubConnector" +def test_github_connector_requires_pygithub_when_constructed_without_extra() -> None: + """GitHub tool metadata imports without PyGithub, but the connector still requires the extra.""" + if importlib.util.find_spec("github") is not None: + pytest.skip("PyGithub is installed") + + from extended_data.connectors.github import GitHubConnector + + with pytest.raises(ImportError, match=r"extended-data\[github\]"): + GitHubConnector(github_owner="jbcom", github_token="token", from_environment=False) + + class TestGitHubToolDefinitions: """Test tool definitions and metadata.""" @@ -73,9 +88,12 @@ def test_list_repositories_basic(self, mock_connector_class): result = list_repositories(github_owner="test-org", github_token="test-token") + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) assert len(result) == 2 assert result[0]["name"] == "repo1" assert result[0]["description"] == "Test repository" + assert isinstance(result[0]["description"], ExtendedString) assert result[1]["name"] == "repo2" @patch(GITHUB_CONNECTOR_PATCH) @@ -112,6 +130,7 @@ def test_get_repository_basic(self, mock_connector_class): result = get_repository(github_owner="test-org", github_token="test-token", repo_name="test-repo") + assert isinstance(result, ExtendedDict) assert result["status"] == "found" assert result["name"] == "test-repo" assert result["full_name"] == "org/test-repo" @@ -127,6 +146,7 @@ def test_get_repository_not_found(self, mock_connector_class): result = get_repository(github_owner="test-org", github_token="test-token", repo_name="nonexistent") + assert isinstance(result, ExtendedDict) assert result["status"] == "not_found" assert result["name"] == "nonexistent" @@ -166,6 +186,8 @@ def test_list_teams_basic(self, mock_connector_class): result = list_teams(github_owner="test-org", github_token="test-token") + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) assert len(result) == 2 assert result[0]["slug"] == "team1" assert result[0]["name"] == "Team 1" @@ -205,6 +227,7 @@ def test_get_team_basic(self, mock_connector_class): result = get_team(github_owner="test-org", github_token="test-token", team_slug="test-team") + assert isinstance(result, ExtendedDict) assert result["status"] == "found" assert result["slug"] == "test-team" assert result["name"] == "Test Team" @@ -220,6 +243,7 @@ def test_get_team_not_found(self, mock_connector_class): result = get_team(github_owner="test-org", github_token="test-token", team_slug="nonexistent") + assert isinstance(result, ExtendedDict) assert result["status"] == "not_found" assert result["slug"] == "nonexistent" @@ -257,6 +281,8 @@ def test_list_org_members_basic(self, mock_connector_class): result = list_org_members(github_owner="test-org", github_token="test-token") + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) assert len(result) == 2 assert result[0]["login"] == "user1" assert result[0]["role"] == "member" @@ -296,6 +322,7 @@ def test_get_repository_file_basic(self, mock_connector_class): file_path="test.json", ) + assert isinstance(result, ExtendedDict) assert result["path"] == "test.json" assert result["content"] == '{"test": "content"}' assert result["sha"] == "abc123" @@ -339,8 +366,30 @@ def test_get_repository_file_empty(self, mock_connector_class): file_path="empty.txt", ) + assert isinstance(result, ExtendedDict) assert result["status"] == "empty" + @patch(GITHUB_CONNECTOR_PATCH) + def test_get_repository_file_single_payload(self, mock_connector_class): + """Test get_repository_file when the connector returns content without SHA metadata.""" + from extended_data.connectors.github.tools import get_repository_file + + mock_connector = MagicMock() + mock_connector.get_repository_file.return_value = "plain content" + mock_connector_class.return_value = mock_connector + + result = get_repository_file( + github_owner="test-org", + github_token="test-token", + github_repo="test-repo", + file_path="README.md", + ) + + assert isinstance(result, ExtendedDict) + assert result["status"] == "retrieved" + assert result["content"] == "plain content" + assert result["sha"] is None + class TestGetTools: """Test framework getters.""" @@ -361,14 +410,118 @@ def test_get_tools_invalid_framework(self): with pytest.raises(ValueError, match="Unknown framework"): get_tools(framework="invalid") - def test_get_tools_strands_alias(self): - """Test get_tools with 'functions' alias for strands.""" + def test_get_tools_rejects_functions_alias(self): + """Plain-function tools should use the canonical strands framework name.""" from extended_data.connectors.github.tools import get_tools - tools = get_tools(framework="functions") + with pytest.raises(ValueError, match="Unknown framework"): + get_tools(framework="functions") - assert len(tools) > 0 - assert all(callable(t) for t in tools) + def test_get_langchain_tools_delegates_shared_builder(self, monkeypatch: pytest.MonkeyPatch): + """LangChain tool factory should pass the GitHub definitions to the shared builder.""" + from extended_data.connectors import ai_tools + from extended_data.connectors.github import tools as github_tools + + expected = [object()] + build_langchain_tools = MagicMock(return_value=expected) + monkeypatch.setattr(ai_tools, "build_langchain_tools", build_langchain_tools) + + assert github_tools.get_langchain_tools() is expected + build_langchain_tools.assert_called_once_with(github_tools.TOOL_DEFINITIONS) + + def test_get_crewai_tools_wraps_definitions(self, monkeypatch: pytest.MonkeyPatch): + """CrewAI tool factory should attach descriptions and schemas to wrapped functions.""" + from extended_data.connectors import _optional + from extended_data.connectors.github import tools as github_tools + + def fake_tool(name): + def decorate(func): + wrapped = MagicMock(wrapped_name=name) + wrapped.__name__ = func.__name__ + return wrapped + + return decorate + + monkeypatch.setattr(_optional, "get_crewai_tool_decorator", lambda: fake_tool) + + tools = github_tools.get_crewai_tools() + + assert len(tools) == len(github_tools.TOOL_DEFINITIONS) + assert tools[0].description == github_tools.TOOL_DEFINITIONS[0]["description"] + assert tools[0].args_schema is github_tools.TOOL_DEFINITIONS[0]["schema"] + + def test_get_crewai_tools_allows_schema_less_definitions(self, monkeypatch: pytest.MonkeyPatch): + """CrewAI tool factory should tolerate definitions without schema metadata.""" + from extended_data.connectors import _optional + from extended_data.connectors.github import tools as github_tools + + class WrappedTool: + pass + + def fake_tool(name): + def decorate(func): + wrapped = WrappedTool() + wrapped.name = name + wrapped.func = func + return wrapped + + return decorate + + monkeypatch.setattr(_optional, "get_crewai_tool_decorator", lambda: fake_tool) + monkeypatch.setattr( + github_tools, + "TOOL_DEFINITIONS", + [{"name": "github_ping", "description": "Ping GitHub", "func": lambda: "pong"}], + ) + + tools = github_tools.get_crewai_tools() + + assert len(tools) == 1 + assert tools[0].description == "Ping GitHub" + assert not hasattr(tools[0], "args_schema") + + def test_get_tools_auto_prefers_crewai_when_available(self, monkeypatch: pytest.MonkeyPatch): + """Auto-detection should prefer CrewAI tools when CrewAI is importable.""" + from extended_data.connectors import _optional + from extended_data.connectors.github import tools as github_tools + + expected = [object()] + monkeypatch.setattr(_optional, "is_available", lambda package: package == "crewai") + monkeypatch.setattr(github_tools, "get_crewai_tools", lambda: expected) + + assert github_tools.get_tools("auto") is expected + + def test_get_tools_auto_falls_back_to_langchain_then_strands(self, monkeypatch: pytest.MonkeyPatch): + """Auto-detection should use LangChain before plain Strands functions.""" + from extended_data.connectors import _optional + from extended_data.connectors.github import tools as github_tools + + langchain_tools = [object()] + strands_tools = [object()] + availability = {"langchain_core": True} + monkeypatch.setattr(_optional, "is_available", lambda package: availability.get(package, False)) + monkeypatch.setattr(github_tools, "get_langchain_tools", lambda: langchain_tools) + monkeypatch.setattr(github_tools, "get_strands_tools", lambda: strands_tools) + + assert github_tools.get_tools("auto") is langchain_tools + + availability["langchain_core"] = False + assert github_tools.get_tools("auto") is strands_tools + + def test_get_tools_explicit_frameworks(self, monkeypatch: pytest.MonkeyPatch): + """Explicit framework names should dispatch to their matching factories.""" + from extended_data.connectors.github import tools as github_tools + + langchain_tools = [object()] + crewai_tools = [object()] + strands_tools = [object()] + monkeypatch.setattr(github_tools, "get_langchain_tools", lambda: langchain_tools) + monkeypatch.setattr(github_tools, "get_crewai_tools", lambda: crewai_tools) + monkeypatch.setattr(github_tools, "get_strands_tools", lambda: strands_tools) + + assert github_tools.get_tools("langchain") is langchain_tools + assert github_tools.get_tools("crewai") is crewai_tools + assert github_tools.get_tools("strands") is strands_tools class TestExports: diff --git a/tests/connectors/test_github_workflow_builder.py b/tests/connectors/test_github_workflow_builder.py index 91775a3..451e692 100644 --- a/tests/connectors/test_github_workflow_builder.py +++ b/tests/connectors/test_github_workflow_builder.py @@ -2,8 +2,11 @@ from __future__ import annotations +from unittest.mock import patch + from ruamel.yaml import YAML +from extended_data.connectors import github as github_module from extended_data.connectors.github import build_github_actions_workflow @@ -18,21 +21,27 @@ def test_build_github_actions_workflow_generates_yaml(): } } - workflow_yaml = build_github_actions_workflow( - workflow_name="CI", - jobs=jobs, - concurrency_group="ci-main", - environment_variables={"FOO": "bar"}, - secrets={"TOKEN": "GITHUB_TOKEN"}, - events={"push": True, "pull_request": False}, - inputs={"run-tests": {"required": False, "type": "boolean", "default": True}}, - ) + with patch( + "extended_data.connectors.github.wrap_raw_data_for_export", + wraps=github_module.wrap_raw_data_for_export, + ) as mock_wrap_for_export: + workflow_yaml = build_github_actions_workflow( + workflow_name="CI", + jobs=jobs, + concurrency_group="ci-main", + environment_variables={"FOO": "bar"}, + secrets={"TOKEN": "GITHUB_TOKEN"}, + events={"push": True, "pull_request": False}, + inputs={"run-tests": {"required": False, "type": "boolean", "default": True}}, + ) parsed = YAML().load(workflow_yaml) assert parsed["name"] == "CI" assert parsed["concurrency"] == "ci-main" assert parsed["env"]["FOO"] == "bar" - assert parsed["env"]["TOKEN"] == "${{ secrets.GITHUB_TOKEN }}" + assert parsed["env"]["TOKEN"] == "${{secrets.GITHUB_TOKEN}}" assert "workflow_dispatch" in parsed["on"] assert parsed["jobs"]["build"]["steps"][1]["run"] == "pytest" + mock_wrap_for_export.assert_called_once() + assert mock_wrap_for_export.call_args.kwargs == {"allow_encoding": "yaml"} diff --git a/tests/connectors/test_google_activity.py b/tests/connectors/test_google_activity.py new file mode 100644 index 0000000..c504346 --- /dev/null +++ b/tests/connectors/test_google_activity.py @@ -0,0 +1,76 @@ +"""Tests for Google project activity helpers without Google SDK imports.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from extended_data.connectors.google.services import GoogleServicesMixin +from extended_data.containers import ExtendedList, extend_data + + +class DummyGoogleServices(GoogleServicesMixin): + """Small concrete test double for GoogleServicesMixin.""" + + def __init__(self, empty_projects: set[str]) -> None: + self.empty_projects = empty_projects + self.logger = MagicMock() + + def is_project_empty(self, project_id: str) -> bool: + return project_id in self.empty_projects + + def extend_result(self, value: Any) -> Any: + return extend_data(value) + + +def test_find_inactive_projects_uses_activity_threshold_for_empty_projects() -> None: + """Recently active empty projects are not reported as inactive.""" + connector = DummyGoogleServices({"old", "recent", "unknown"}) + projects: dict[str, dict[str, Any]] = { + "old": { + "projectId": "old", + "lifecycleState": "ACTIVE", + "updateTime": "2000-01-01T00:00:00Z", + }, + "recent": { + "projectId": "recent", + "lifecycleState": "ACTIVE", + "updateTime": "2999-01-01T00:00:00Z", + }, + "unknown": { + "projectId": "unknown", + "lifecycleState": "ACTIVE", + }, + } + + inactive = connector.find_inactive_projects(projects, days_since_activity=90) + + assert isinstance(inactive, ExtendedList) + assert {project["projectId"] for project in inactive} == {"old", "unknown"} + assert projects["old"]["inactive_reason"] == "no_resources_since=2000-01-01" + assert projects["unknown"]["inactive_reason"] == "no_resources" + assert "inactive_reason" not in projects["recent"] + + +def test_find_inactive_projects_keeps_non_empty_active_projects() -> None: + """Active projects with resources are not inactive solely because timestamps are old.""" + connector = DummyGoogleServices(set()) + projects = { + "active": { + "projectId": "active", + "lifecycleState": "ACTIVE", + "updateTime": "2000-01-01T00:00:00Z", + } + } + + assert connector.find_inactive_projects(projects, days_since_activity=90) == [] + + +def test_find_inactive_projects_rejects_negative_activity_threshold() -> None: + """Negative activity thresholds fail instead of silently widening the query.""" + connector = DummyGoogleServices(set()) + + with pytest.raises(ValueError, match="days_since_activity"): + connector.find_inactive_projects({}, days_since_activity=-1) diff --git a/tests/connectors/test_google_billing.py b/tests/connectors/test_google_billing.py index d199ef5..2c1dfab 100644 --- a/tests/connectors/test_google_billing.py +++ b/tests/connectors/test_google_billing.py @@ -1,3 +1,4 @@ +# ruff: noqa: I001 """Tests for Google Billing mixin helpers.""" from __future__ import annotations @@ -5,16 +6,29 @@ from collections import deque from collections.abc import Iterable from typing import Any +from unittest.mock import MagicMock, patch +import pytest + +pytest.importorskip("google.oauth2.service_account") +pytest.importorskip("googleapiclient") + +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString, extend_data from extended_data.connectors.google.billing import GoogleBillingMixin -class _StubLogger: - def info(self, *args, **kwargs): # pragma: no cover - pass-through logger stub - pass +def _logged_text(logger: MagicMock) -> str: + """Return concatenated mock logger messages.""" + return "\n".join(str(arg) for call in logger.method_calls for arg in call.args) + + +def _http_error(status: int): + """Return a Google API HttpError with the requested status.""" + from googleapiclient.errors import HttpError - def warning(self, *args, **kwargs): # pragma: no cover - pass + response = MagicMock() + response.status = status + return HttpError(response, b"Google API error") class _ImmediateResponse: @@ -79,13 +93,23 @@ def projects(self): class _TestGoogleBilling(GoogleBillingMixin): - def __init__(self, service: _StubBillingService): - self.logger = _StubLogger() + def __init__(self, service: Any): + self.logger = MagicMock() self._service = service + self.service_account_info = { + "type": "service_account", + "client_email": "test@example.iam.gserviceaccount.com", + "private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIIE...test\n-----END RSA PRIVATE KEY-----\n", + "private_key_id": "key123", + "project_id": "test-project", + } def get_billing_service(self): return self._service + def extend_result(self, value: Any) -> Any: + return extend_data(value) + def test_list_billing_accounts_paginates_and_unhumps(): service = _StubBillingService( @@ -108,6 +132,9 @@ def test_list_billing_accounts_paginates_and_unhumps(): accounts = connector.list_billing_accounts(filter_query="parent:organizations/1", unhump_accounts=True) + assert isinstance(accounts, ExtendedList) + assert isinstance(accounts[0], ExtendedDict) + assert isinstance(accounts[0]["display_name"], ExtendedString) assert [acct["name"] for acct in accounts] == ["billingAccounts/ABC", "billingAccounts/DEF"] # Ensure snake_case conversion applied assert accounts[0]["display_name"] == "Primary" @@ -117,12 +144,92 @@ def test_list_billing_accounts_paginates_and_unhumps(): ] +def test_get_billing_account_prefixes_name_and_promotes_result(): + service = MagicMock() + service.billingAccounts.return_value.get.return_value.execute.return_value = { + "name": "billingAccounts/1234-ABCD", + "displayName": "Primary", + } + connector = _TestGoogleBilling(service) + + account = connector.get_billing_account("1234-ABCD") + + assert isinstance(account, ExtendedDict) + assert account["name"] == "billingAccounts/1234-ABCD" + assert isinstance(account["displayName"], ExtendedString) + service.billingAccounts.return_value.get.assert_called_once_with(name="billingAccounts/1234-ABCD") + + +def test_get_billing_account_accepts_prefixed_name(): + service = MagicMock() + service.billingAccounts.return_value.get.return_value.execute.return_value = { + "name": "billingAccounts/1234-ABCD", + } + connector = _TestGoogleBilling(service) + + connector.get_billing_account("billingAccounts/1234-ABCD") + + service.billingAccounts.return_value.get.assert_called_once_with(name="billingAccounts/1234-ABCD") + + +def test_get_billing_account_returns_none_for_not_found(): + service = MagicMock() + service.billingAccounts.return_value.get.return_value.execute.side_effect = _http_error(404) + connector = _TestGoogleBilling(service) + + account = connector.get_billing_account("private-account@example.com") + + assert account is None + logs = _logged_text(connector.logger) + assert "[REDACTED]" in logs + assert "private-account@example.com" not in logs + + +def test_get_billing_account_reraises_unexpected_errors(): + service = MagicMock() + service.billingAccounts.return_value.get.return_value.execute.side_effect = _http_error(403) + connector = _TestGoogleBilling(service) + + with pytest.raises(Exception, match="Google API error"): + connector.get_billing_account("1234-ABCD") + + +def test_get_project_billing_info_promotes_result(): + service = MagicMock() + service.projects.return_value.getBillingInfo.return_value.execute.return_value = { + "name": "projects/demo-project/billingInfo", + "billingEnabled": True, + } + connector = _TestGoogleBilling(service) + + info = connector.get_project_billing_info("demo-project") + + assert isinstance(info, ExtendedDict) + assert info["billingEnabled"] is True + service.projects.return_value.getBillingInfo.assert_called_once_with(name="projects/demo-project") + + +def test_get_project_billing_info_returns_none_for_not_found(): + service = MagicMock() + service.projects.return_value.getBillingInfo.return_value.execute.side_effect = _http_error(404) + connector = _TestGoogleBilling(service) + + info = connector.get_project_billing_info("secret-project@example.com") + + assert info is None + logs = _logged_text(connector.logger) + assert "[REDACTED]" in logs + assert "secret-project@example.com" not in logs + + def test_update_project_billing_info_prefixes_account_name(): service = _StubBillingService(account_responses=[], project_responses=[]) connector = _TestGoogleBilling(service) response = connector.update_project_billing_info("demo-project", "1234-ABCD") + assert isinstance(response, ExtendedDict) + assert isinstance(response["billingAccountName"], ExtendedString) assert response["billingAccountName"] == "billingAccounts/1234-ABCD" assert service.projects().update_calls == [ { @@ -132,12 +239,32 @@ def test_update_project_billing_info_prefixes_account_name(): ] +def test_update_project_billing_info_logs_redact_identifiers_but_preserve_call_args(): + service = _StubBillingService(account_responses=[], project_responses=[]) + connector = _TestGoogleBilling(service) + + connector.update_project_billing_info("sensitive-project", "1234-PRIVATE") + + assert service.projects().update_calls == [ + { + "name": "projects/sensitive-project", + "body": {"billingAccountName": "billingAccounts/1234-PRIVATE"}, + } + ] + logs = _logged_text(connector.logger) + assert "[REDACTED]" in logs + assert "sensitive-project" not in logs + assert "1234-PRIVATE" not in logs + + def test_disable_project_billing_sets_empty_account(): service = _StubBillingService(account_responses=[], project_responses=[]) connector = _TestGoogleBilling(service) response = connector.disable_project_billing("demo-project") + assert isinstance(response, ExtendedDict) + assert isinstance(response["billingAccountName"], ExtendedString) assert response["billingAccountName"] == "" assert service.projects().update_calls[-1] == { "name": "projects/demo-project", @@ -162,8 +289,168 @@ def test_list_billing_account_projects_handles_prefixing(): projects = connector.list_billing_account_projects("123456-AAAA", unhump_projects=True) + assert isinstance(projects, ExtendedList) + assert isinstance(projects[0], ExtendedDict) + assert isinstance(projects[0]["project_id"], ExtendedString) assert [proj["project_id"] for proj in projects] == ["alpha", "beta"] assert service.billingAccounts().projects().list_calls == [ {"name": "billingAccounts/123456-AAAA"}, {"name": "billingAccounts/123456-AAAA", "pageToken": "p1"}, ] + + +def test_get_billing_account_iam_policy_prefixes_resource(): + service = MagicMock() + service.billingAccounts.return_value.getIamPolicy.return_value.execute.return_value = { + "bindings": [{"role": "roles/billing.viewer"}], + } + connector = _TestGoogleBilling(service) + + policy = connector.get_billing_account_iam_policy("123456-AAAA") + + assert isinstance(policy, ExtendedDict) + assert policy["bindings"][0]["role"] == "roles/billing.viewer" + service.billingAccounts.return_value.getIamPolicy.assert_called_once_with(resource="billingAccounts/123456-AAAA") + + +def test_set_billing_account_iam_policy_lowers_extended_policy(): + service = MagicMock() + service.billingAccounts.return_value.setIamPolicy.return_value.execute.return_value = { + "bindings": [{"role": "roles/billing.admin"}], + } + connector = _TestGoogleBilling(service) + + policy = connector.set_billing_account_iam_policy( + "123456-AAAA", + extend_data({"bindings": [{"role": "roles/billing.admin"}]}), + ) + + assert isinstance(policy, ExtendedDict) + service.billingAccounts.return_value.setIamPolicy.assert_called_once_with( + resource="billingAccounts/123456-AAAA", + body={"policy": {"bindings": [{"role": "roles/billing.admin"}]}}, + ) + + +def test_get_bigquery_billing_dataset_filters_billing_tables(): + service = _StubBillingService(account_responses=[], project_responses=[]) + connector = _TestGoogleBilling(service) + credentials = MagicMock(name="credentials") + bigquery = MagicMock() + bigquery.datasets.return_value.get.return_value.execute.return_value = { + "datasetReference": {"datasetId": "billing_export"}, + "location": "US", + "description": "Billing export", + } + bigquery.tables.return_value.list.return_value.execute.return_value = { + "tables": [ + {"tableReference": {"tableId": "gcp_billing_export_v1_123"}}, + {"tableReference": {"tableId": "not_billing"}}, + ] + } + + with ( + patch("google.oauth2.service_account.Credentials.from_service_account_info", return_value=credentials) as from_info, + patch("googleapiclient.discovery.build", return_value=bigquery) as build, + ): + result = connector.get_bigquery_billing_dataset("billing-project", "billing_export") + + assert isinstance(result, ExtendedDict) + assert result["location"] == "US" + assert len(result["billing_tables"]) == 1 + assert result["billing_tables"][0]["tableReference"]["tableId"] == "gcp_billing_export_v1_123" + from_info.assert_called_once_with( + connector.service_account_info, + scopes=["https://www.googleapis.com/auth/bigquery.readonly"], + ) + build.assert_called_once_with("bigquery", "v2", credentials=credentials, cache_discovery=False) + + +def test_get_bigquery_billing_dataset_returns_none_for_missing_dataset(): + service = _StubBillingService(account_responses=[], project_responses=[]) + connector = _TestGoogleBilling(service) + credentials = MagicMock(name="credentials") + bigquery = MagicMock() + bigquery.datasets.return_value.get.return_value.execute.side_effect = _http_error(404) + + with ( + patch("google.oauth2.service_account.Credentials.from_service_account_info", return_value=credentials), + patch("googleapiclient.discovery.build", return_value=bigquery), + ): + result = connector.get_bigquery_billing_dataset("secret-project@example.com", "billing@example.com") + + assert result is None + logs = _logged_text(connector.logger) + assert "[REDACTED]" in logs + assert "secret-project@example.com" not in logs + assert "billing@example.com" not in logs + + +def test_setup_billing_export_returns_existing_dataset_config(): + service = _StubBillingService(account_responses=[], project_responses=[]) + connector = _TestGoogleBilling(service) + credentials = MagicMock(name="credentials") + bigquery = MagicMock() + bigquery.datasets.return_value.get.return_value.execute.return_value = {"location": "EU"} + + with ( + patch("google.oauth2.service_account.Credentials.from_service_account_info", return_value=credentials) as from_info, + patch("googleapiclient.discovery.build", return_value=bigquery) as build, + ): + result = connector.setup_billing_export( + "123456-AAAA", + "billing-project", + dataset_id="billing_export", + location="EU", + ) + + assert isinstance(result, ExtendedDict) + assert result["billing_account_id"] == "123456-AAAA" + assert result["project_id"] == "billing-project" + assert result["dataset_id"] == "billing_export" + assert result["location"] == "EU" + assert result["full_dataset_id"] == "billing-project.billing_export" + bigquery.datasets.return_value.insert.assert_not_called() + from_info.assert_called_once_with( + connector.service_account_info, + scopes=["https://www.googleapis.com/auth/bigquery"], + ) + build.assert_called_once_with("bigquery", "v2", credentials=credentials, cache_discovery=False) + + +def test_setup_billing_export_creates_missing_dataset(): + service = _StubBillingService(account_responses=[], project_responses=[]) + connector = _TestGoogleBilling(service) + credentials = MagicMock(name="credentials") + bigquery = MagicMock() + bigquery.datasets.return_value.get.return_value.execute.side_effect = _http_error(404) + bigquery.datasets.return_value.insert.return_value.execute.return_value = {"location": "US"} + + with ( + patch("google.oauth2.service_account.Credentials.from_service_account_info", return_value=credentials), + patch("googleapiclient.discovery.build", return_value=bigquery), + ): + result = connector.setup_billing_export("123456-AAAA", "billing-project") + + assert isinstance(result, ExtendedDict) + assert result["location"] == "US" + insert_call = bigquery.datasets.return_value.insert.call_args + assert insert_call.kwargs["projectId"] == "billing-project" + body = insert_call.kwargs["body"] + assert body["datasetReference"] == {"projectId": "billing-project", "datasetId": "billing_export"} + assert body["labels"]["billing_account"] == "123456_AAAA" + + +def test_setup_billing_export_reraises_unexpected_dataset_errors(): + service = _StubBillingService(account_responses=[], project_responses=[]) + connector = _TestGoogleBilling(service) + credentials = MagicMock(name="credentials") + bigquery = MagicMock() + bigquery.datasets.return_value.get.return_value.execute.side_effect = _http_error(403) + + with ( + patch("google.oauth2.service_account.Credentials.from_service_account_info", return_value=credentials), + patch("googleapiclient.discovery.build", return_value=bigquery), + ): + with pytest.raises(Exception, match="Google API error"): + connector.setup_billing_export("123456-AAAA", "billing-project") diff --git a/tests/connectors/test_google_cloud.py b/tests/connectors/test_google_cloud.py index 23338cf..77520ab 100644 --- a/tests/connectors/test_google_cloud.py +++ b/tests/connectors/test_google_cloud.py @@ -1,3 +1,4 @@ +# ruff: noqa: I001 """Tests for Google Cloud Platform resource management operations.""" from __future__ import annotations @@ -6,7 +7,16 @@ import pytest -from extended_data.connectors.google import GoogleConnectorFull +pytest.importorskip("google.oauth2.service_account") +pytest.importorskip("googleapiclient") + +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString, extend_data +from extended_data.connectors.google import GoogleConnector + + +def _logged_text(logger: MagicMock) -> str: + """Return concatenated mock logger messages.""" + return "\n".join(str(arg) for call in logger.method_calls for arg in call.args) @pytest.fixture @@ -20,7 +30,7 @@ def google_connector(): "project_id": "test-project", } with patch("googleapiclient.discovery.build"): - connector = GoogleConnectorFull(service_account_info=service_account) + connector = GoogleConnector(service_account_info=service_account) connector.logger = MagicMock() return connector @@ -39,6 +49,7 @@ def test_get_organization_id(self, google_connector): result = google_connector.get_organization_id() + assert isinstance(result, ExtendedString) assert result == "123456789" def test_get_organization_id_no_org(self, google_connector): @@ -68,6 +79,8 @@ def test_get_organization(self, google_connector): result = google_connector.get_organization() + assert isinstance(result, ExtendedDict) + assert isinstance(result["displayName"], ExtendedString) assert result["displayName"] == "Test Org" assert result["lifecycleState"] == "ACTIVE" @@ -99,6 +112,9 @@ def test_list_projects(self, google_connector): result = google_connector.list_projects() + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) + assert isinstance(result[0]["projectId"], ExtendedString) assert len(result) == 2 assert result[0]["projectId"] == "project-1" @@ -161,6 +177,8 @@ def test_get_project(self, google_connector): result = google_connector.get_project("test-project") + assert isinstance(result, ExtendedDict) + assert isinstance(result["projectId"], ExtendedString) assert result["projectId"] == "test-project" assert result["lifecycleState"] == "ACTIVE" @@ -176,8 +194,27 @@ def test_create_project(self, google_connector): result = google_connector.create_project("new-project", "New Project") + assert isinstance(result, ExtendedDict) + assert isinstance(result["projectId"], ExtendedString) assert result["projectId"] == "new-project" + def test_create_project_logs_redact_identifier_but_preserve_body(self, google_connector): + """Project creation logs should not expose project IDs.""" + mock_service = MagicMock() + mock_projects = mock_service.projects.return_value + mock_projects.create.return_value.execute.return_value = { + "projectId": "sensitive-project", + "name": "Sensitive Project", + } + google_connector.get_cloud_resource_manager_service = MagicMock(return_value=mock_service) + + google_connector.create_project("sensitive-project", "Sensitive Project") + + assert mock_projects.create.call_args.kwargs["body"]["projectId"] == "sensitive-project" + logs = _logged_text(google_connector.logger) + assert "[REDACTED]" in logs + assert "sensitive-project" not in logs + def test_delete_project(self, google_connector): """Test deleting a project.""" mock_service = MagicMock() @@ -207,6 +244,9 @@ def test_list_folders(self, google_connector): result = google_connector.list_folders(parent="organizations/123456") + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) + assert isinstance(result[0]["displayName"], ExtendedString) assert len(result) == 2 assert result[0]["displayName"] == "Folder One" @@ -231,6 +271,8 @@ def test_get_iam_policy(self, google_connector): result = google_connector.get_iam_policy("test-project") + assert isinstance(result, ExtendedDict) + assert isinstance(result["bindings"], ExtendedList) assert len(result["bindings"]) == 1 assert result["bindings"][0]["role"] == "roles/owner" @@ -248,17 +290,42 @@ def test_set_iam_policy(self, google_connector): } google_connector.get_cloud_resource_manager_service = MagicMock(return_value=mock_service) - policy = { - "bindings": [ - { - "role": "roles/viewer", - "members": ["user:viewer@example.com"], - } - ] - } + policy = extend_data( + { + "bindings": [ + { + "role": "roles/viewer", + "members": ["user:viewer@example.com"], + } + ] + } + ) result = google_connector.set_iam_policy("test-project", policy) + assert isinstance(result, ExtendedDict) + assert isinstance(result["bindings"], ExtendedList) assert result["bindings"][0]["role"] == "roles/viewer" + call_body = mock_projects.setIamPolicy.call_args.kwargs["body"] + assert isinstance(call_body["policy"], dict) + + def test_add_iam_binding_logs_redact_member_and_resource_but_preserve_policy(self, google_connector): + """IAM binding logs should redact member/resource identifiers without changing policy.""" + google_connector.get_iam_policy = MagicMock(return_value=extend_data({"bindings": []})) + google_connector.set_iam_policy = MagicMock(return_value=extend_data({"bindings": []})) + + google_connector.add_iam_binding( + "sensitive-project", + "roles/viewer", + "user:sensitive.user@example.com", + ) + + policy = google_connector.set_iam_policy.call_args.args[1] + assert policy["bindings"][0]["members"] == ["user:sensitive.user@example.com"] + logs = _logged_text(google_connector.logger) + assert "[REDACTED]" in logs + assert "sensitive-project" not in logs + assert "sensitive.user@example.com" not in logs + assert "roles/viewer" in logs def test_list_service_accounts(self, google_connector): """Test listing service accounts.""" @@ -280,6 +347,9 @@ def test_list_service_accounts(self, google_connector): result = google_connector.list_service_accounts("test-project") + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) + assert isinstance(result[0]["displayName"], ExtendedString) assert len(result) == 2 assert result[0]["displayName"] == "Service Account 1" @@ -299,4 +369,6 @@ def test_create_service_account(self, google_connector): "New Service Account", ) + assert isinstance(result, ExtendedDict) + assert isinstance(result["displayName"], ExtendedString) assert result["displayName"] == "New Service Account" diff --git a/tests/connectors/test_google_connector.py b/tests/connectors/test_google_connector.py index a363bf6..716f58d 100644 --- a/tests/connectors/test_google_connector.py +++ b/tests/connectors/test_google_connector.py @@ -1,16 +1,21 @@ +# ruff: noqa: I001 """Tests for GoogleConnector.""" from __future__ import annotations from unittest.mock import MagicMock, patch -from extended_data.connectors.google import ( - GoogleBillingConnector, - GoogleCloudConnector, - GoogleConnector, - GoogleConnectorFull, - GoogleWorkspaceConnector, -) +import pytest +pytest.importorskip("google.oauth2.service_account") +pytest.importorskip("googleapiclient") + +from extended_data.containers import ExtendedDict, ExtendedString +from extended_data.connectors.google import GoogleConnector + + +def _logged_text(logger: MagicMock) -> str: + """Return concatenated mock logger messages.""" + return "\n".join(str(arg) for call in logger.method_calls for arg in call.args) def _service_account(): @@ -84,6 +89,51 @@ def test_init_with_dict_service_account(self, base_connector_kwargs): assert connector.service_account_info == service_account assert connector._credentials is None + @patch("extended_data.connectors.google.decode_file") + def test_init_decodes_service_account_string_through_data_boundary(self, mock_decode_file, base_connector_kwargs): + """Service-account JSON strings should use the shared data decoder.""" + service_account = _service_account() + service_account_text = '{"type": "service_account"}' + mock_decode_file.return_value = service_account + + connector = GoogleConnector( + service_account_info=service_account_text, + **base_connector_kwargs, + ) + + assert connector.service_account_info == service_account + mock_decode_file.assert_called_once_with(service_account_text, suffix="json", as_extended=False) + + def test_init_redacts_invalid_service_account_json_logs(self, base_connector_kwargs): + """Invalid service-account JSON diagnostics should not expose key material.""" + invalid_service_account = '{"private_key": "-----BEGIN RSA PRIVATE KEY-----\\nMIIE...test"' + + with pytest.raises(ValueError) as exc_info: + GoogleConnector(service_account_info=invalid_service_account, **base_connector_kwargs) + + logs = _logged_text(base_connector_kwargs["logger"].logger) + diagnostics = logs + str(exc_info.value) + assert "MIIE...test" not in diagnostics + assert "BEGIN RSA PRIVATE KEY" not in diagnostics + assert "[REDACTED]" in diagnostics + assert exc_info.value.__cause__ is None + assert all("exc_info" not in logged_call.kwargs for logged_call in base_connector_kwargs["logger"].logger.method_calls) + + @patch("extended_data.connectors.google.decode_file") + def test_sequence_option_input_decodes_json_through_data_boundary(self, mock_decode_file, base_connector_kwargs): + """List-like Google input values should use the shared data decoder.""" + mock_decode_file.return_value = ["/Engineering", "/Platform"] + connector = GoogleConnector( + service_account_info=_service_account(), + inputs={"GOOGLE_OU_ALLOW_LIST": '["/Engineering", "/Platform"]'}, + **base_connector_kwargs, + ) + + result = connector._resolve_sequence_option(None, "GOOGLE_OU_ALLOW_LIST") + + assert result == ["/Engineering", "/Platform"] + mock_decode_file.assert_called_once_with('["/Engineering", "/Platform"]', suffix="json", as_extended=False) + @patch("extended_data.connectors.google.service_account.Credentials.from_service_account_info") def test_credentials_property(self, mock_from_sa, base_connector_kwargs): """Test credentials property creates credentials.""" @@ -191,6 +241,9 @@ def test_list_users_filters_and_transforms(self, mock_get_service, base_connecto key_by_email=True, ) + assert isinstance(result, ExtendedDict) + assert isinstance(result["engineer@example.com"], ExtendedDict) + assert isinstance(result["engineer@example.com"]["full_name"], ExtendedString) assert "bot@example.com" not in result assert "suspended@example.com" not in result assert "sales@example.com" not in result @@ -223,25 +276,28 @@ def test_list_groups_key_by_email_and_filters(self, mock_get_service, base_conne key_by_email=True, ) + assert isinstance(result, ExtendedDict) + assert isinstance(result["keepers@example.com"], ExtendedDict) assert "bots@example.com" not in result assert "keepers@example.com" in result assert result["keepers@example.com"]["suspended"] is True assert "team@example.com" in result assert result["team@example.com"]["primaryEmail"] == "team@example.com" - def test_specialized_connector_exports_match_available_operations(self, base_connector_kwargs): - """Specialized Google connectors expose the operations their entry points advertise.""" + def test_unified_connector_exposes_all_google_operations(self, base_connector_kwargs): + """The single Google connector exposes Workspace, Cloud, and Billing operations.""" service_account = _service_account() - cloud = GoogleCloudConnector(service_account_info=service_account, **base_connector_kwargs) - workspace = GoogleWorkspaceConnector(service_account_info=service_account, **base_connector_kwargs) - billing = GoogleBillingConnector(service_account_info=service_account, **base_connector_kwargs) - full = GoogleConnectorFull(service_account_info=service_account, **base_connector_kwargs) + connector = GoogleConnector(service_account_info=service_account, **base_connector_kwargs) + + assert hasattr(connector, "list_projects") + assert hasattr(connector, "list_users") + assert hasattr(connector, "list_billing_accounts") - assert hasattr(cloud, "list_projects") - assert hasattr(workspace, "list_users") - assert hasattr(billing, "list_billing_accounts") + def test_specialized_google_connector_aliases_are_not_preserved(self): + """Clean major-version surface should keep Google operations on GoogleConnector.""" + import extended_data.connectors.google as google_module - assert hasattr(full, "list_projects") - assert hasattr(full, "list_users") - assert hasattr(full, "list_billing_accounts") + assert not hasattr(google_module, "GoogleCloudConnector") + assert not hasattr(google_module, "GoogleWorkspaceConnector") + assert not hasattr(google_module, "GoogleBillingConnector") diff --git a/tests/connectors/test_google_jules.py b/tests/connectors/test_google_jules.py new file mode 100644 index 0000000..346b8df --- /dev/null +++ b/tests/connectors/test_google_jules.py @@ -0,0 +1,396 @@ +"""Tests for the Google Jules connector.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import httpx +import pytest + +from extended_data.connectors.google.jules import JulesConnector, JulesError, Session +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString + + +def _response(payload: object, status_code: int = 200) -> httpx.Response: + response = httpx.Response( + status_code, + json=payload, + request=httpx.Request("GET", "https://jules.googleapis.com/v1alpha/test"), + ) + response.json = MagicMock(side_effect=AssertionError("Jules responses must be decoded from content bytes")) + return response + + +def _text_response(text: str, status_code: int = 500, url: str = "https://jules.googleapis.com/v1alpha/test") -> httpx.Response: + response = httpx.Response( + status_code, + text=text, + request=httpx.Request("GET", url), + ) + response.json = MagicMock(side_effect=AssertionError("Jules responses must be decoded from content bytes")) + return response + + +def test_session_pull_request_model_property() -> None: + """The standalone Session model still exposes typed convenience properties.""" + session = Session( + name="sessions/123", + outputs=[ + { + "pullRequest": { + "url": "https://github.com/org/repo/pull/1", + "title": "Fix", + } + } + ], + ) + + assert session.pull_request is not None + assert session.pull_request.url == "https://github.com/org/repo/pull/1" + assert session.pull_request.title == "Fix" + + +def test_list_sources_returns_extended_payloads() -> None: + """Jules source lists are promoted into extended containers.""" + connector = JulesConnector(api_key="test-key") + connector.get = MagicMock( + return_value=_response( + { + "sources": [ + { + "name": "sources/github/org/repo", + "id": "repo", + "githubRepo": {"owner": "org", "name": "repo"}, + } + ] + } + ) + ) + + result = connector.list_sources(page_size=10, page_token="next") + + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) + assert isinstance(result[0]["name"], ExtendedString) + assert isinstance(result[0]["githubRepo"], ExtendedDict) + assert result[0]["githubRepo"]["owner"] == "org" + connector.get.assert_called_once_with("/sources", params={"pageSize": 10, "pageToken": "next"}) + + +def test_create_session_returns_extended_payload() -> None: + """Created sessions are returned as extended payloads.""" + connector = JulesConnector(api_key="test-key") + connector.post = MagicMock( + return_value=_response( + { + "name": "sessions/123", + "id": "123", + "title": "Fix login", + "state": "RUNNING", + "sourceContext": { + "source": "sources/github/org/repo", + "githubRepoContext": {"startingBranch": "main"}, + }, + } + ) + ) + + result = connector.create_session( + prompt="Fix login", + source="sources/github/org/repo", + title="Fix login", + require_plan_approval=True, + ) + + assert isinstance(result, ExtendedDict) + assert isinstance(result["sourceContext"], ExtendedDict) + assert isinstance(result["sourceContext"]["githubRepoContext"], ExtendedDict) + assert result["name"] == "sessions/123" + connector.post.assert_called_once() + body = connector.post.call_args.kwargs["json"] + assert body["requirePlanApproval"] is True + assert body["sourceContext"]["githubRepoContext"]["startingBranch"] == "main" + + +def test_get_session_accepts_id_and_returns_extended_payload() -> None: + """Session lookup accepts a bare ID and returns an extended session payload.""" + connector = JulesConnector(api_key="test-key") + connector.get = MagicMock(return_value=_response({"name": "sessions/123", "id": "123", "state": "COMPLETED"})) + + result = connector.get_session("123") + + assert isinstance(result, ExtendedDict) + assert isinstance(result["state"], ExtendedString) + assert result["name"] == "sessions/123" + connector.get.assert_called_once_with("/sessions/123") + + +def test_list_sessions_returns_extended_payloads() -> None: + """Jules session lists are promoted into extended containers.""" + connector = JulesConnector(api_key="test-key") + connector.get = MagicMock( + return_value=_response( + { + "sessions": [ + {"name": "sessions/1", "id": "1", "state": "RUNNING"}, + {"name": "sessions/2", "id": "2", "state": "COMPLETED"}, + ] + } + ) + ) + + result = connector.list_sessions(page_size=2) + + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) + assert result[1]["state"] == "COMPLETED" + + +def test_approve_plan_returns_updated_extended_session() -> None: + """Plan approval fetches and returns the updated extended session.""" + connector = JulesConnector(api_key="test-key") + connector.post = MagicMock(return_value=_response({})) + connector.get_session = MagicMock(return_value=ExtendedDict({"name": "sessions/123", "state": "RUNNING"})) + + result = connector.approve_plan("123") + + assert isinstance(result, ExtendedDict) + assert result["name"] == "sessions/123" + connector.post.assert_called_once_with("/sessions/123:approvePlan") + connector.get_session.assert_called_once_with("sessions/123") + + +def test_add_user_response_sends_prompt_and_returns_updated_session() -> None: + """Jules follow-up messages are sent through the required sendMessage prompt body.""" + connector = JulesConnector(api_key="test-key") + connector.post = MagicMock(return_value=_response({})) + connector.get_session = MagicMock(return_value=ExtendedDict({"name": "sessions/123", "state": "RUNNING"})) + + result = connector.add_user_response("123", "Please continue with the tests") + + assert isinstance(result, ExtendedDict) + connector.post.assert_called_once_with("/sessions/123:sendMessage", json={"prompt": "Please continue with the tests"}) + connector.get_session.assert_called_once_with("sessions/123") + + +def test_add_user_response_requires_non_empty_prompt() -> None: + """The Jules sendMessage API requires a prompt, so empty local calls should fail.""" + connector = JulesConnector(api_key="test-key") + + with pytest.raises(ValueError, match="non-empty prompt"): + connector.add_user_response("123", "") + + +def test_resume_session_sends_prompt_via_add_user_response() -> None: + """The resume helper should keep the same required prompt contract.""" + connector = JulesConnector(api_key="test-key") + connector.add_user_response = MagicMock(return_value=ExtendedDict({"name": "sessions/123", "state": "RUNNING"})) + + result = connector.resume_session("123", "Resume with the approved plan") + + assert result["name"] == "sessions/123" + connector.add_user_response.assert_called_once_with("123", "Resume with the approved plan") + + +def test_handle_response_raises_jules_error() -> None: + """Jules API errors preserve vendor message and status details.""" + connector = JulesConnector(api_key="test-key") + response = _response({"error": {"message": "denied", "code": 403, "details": [{"reason": "forbidden"}]}}, 403) + + with pytest.raises(JulesError) as exc_info: + connector._handle_response(response, "test_operation") + + assert exc_info.value.code == 403 + assert exc_info.value.details == [{"reason": "forbidden"}] + + +def test_handle_response_redacts_sensitive_jules_error_details() -> None: + """Jules API errors should not expose raw secret-bearing fields.""" + connector = JulesConnector(api_key="test-key") + response = _response( + { + "error": { + "message": "denied password=hunter2 Bearer raw_token", + "code": 403, + "details": [{"api_key": "key_123"}], + } + }, + 403, + ) + + with pytest.raises(JulesError) as exc_info: + connector._handle_response(response, "test_operation") + + message = str(exc_info.value) + assert "hunter2" not in message + assert "raw_token" not in message + assert exc_info.value.details == [{"api_key": "[REDACTED]"}] + + +def test_handle_response_redacts_request_url_in_jules_error() -> None: + """Jules API errors should redact caller-controlled request URLs.""" + connector = JulesConnector(api_key="test-key") + request_url = "https://jules.googleapis.com/v1alpha/sessions/private-session?api_key=raw_key" + response = httpx.Response( + 403, + json={ + "error": { + "message": f"denied while calling {request_url}", + "code": 403, + "details": [{"debug": request_url}], + } + }, + request=httpx.Request("GET", request_url), + ) + + with pytest.raises(JulesError) as exc_info: + connector._handle_response(response, "get_session", "sessions/private-session") + + error = exc_info.value + assert request_url not in str(error) + assert request_url not in repr(error.details) + assert error.__cause__ is None + + +def test_handle_response_malformed_error_has_sanitized_message_without_cause() -> None: + """Malformed Jules errors should not chain parser internals or expose request URLs.""" + connector = JulesConnector(api_key="test-key") + request_url = "https://jules.googleapis.com/v1alpha/sessions/private-session?api_key=raw_key" + response = _text_response( + f"upstream failed while calling {request_url} with password=hunter2", + status_code=502, + url=request_url, + ) + + with pytest.raises(JulesError) as exc_info: + connector._handle_response(response, "get_session", "sessions/private-session") + + error = exc_info.value + message = str(error) + assert error.code == 502 + assert error.__cause__ is None + assert request_url not in message + assert "hunter2" not in message + + +def test_handle_response_rejects_non_object_success_response() -> None: + """Successful Jules responses must still be JSON objects.""" + connector = JulesConnector(api_key="test-key") + request_url = "https://jules.googleapis.com/v1alpha/sessions/private-session?api_key=raw_key" + response = httpx.Response( + 200, + json=["sessions/private-session", {"password": "hunter2"}], + request=httpx.Request("GET", request_url), + ) + + with pytest.raises(JulesError, match="Unexpected Jules response for get_session") as exc_info: + connector._handle_response(response, "get_session", "sessions/private-session") + + message = str(exc_info.value) + assert exc_info.value.code == 200 + assert "sessions/private-session" not in message + assert "hunter2" not in message + assert "raw_key" not in message + assert "[REDACTED]" in message + assert exc_info.value.__cause__ is None + + +def test_handle_response_redacts_non_object_error_response() -> None: + """Non-object Jules error JSON should still become a sanitized JulesError.""" + connector = JulesConnector(api_key="test-key") + request_url = "https://jules.googleapis.com/v1alpha/sessions/private-session?api_key=raw_key" + response = httpx.Response( + 500, + json=["sessions/private-session", "password=hunter2 Authorization: Bearer raw_token"], + request=httpx.Request("GET", request_url), + ) + + with pytest.raises(JulesError) as exc_info: + connector._handle_response(response, "get_session", "sessions/private-session") + + message = str(exc_info.value) + assert exc_info.value.code == 500 + assert "sessions/private-session" not in message + assert "hunter2" not in message + assert "raw_token" not in message + assert "raw_key" not in message + assert "[REDACTED]" in message + assert exc_info.value.__cause__ is None + + +def test_create_session_malformed_response_is_redacted() -> None: + """Created session payloads should validate without exposing prompts or sources.""" + connector = JulesConnector(api_key="test-key") + connector.post = MagicMock( + return_value=_response( + { + "id": "123", + "debug": "Fix private prompt", + "source": "sources/github/private-org/private-repo", + "password": "hunter2", + } + ) + ) + + with pytest.raises(JulesError, match="Unexpected Jules response for create_session") as exc_info: + connector.create_session( + prompt="Fix private prompt", + source="sources/github/private-org/private-repo", + title="private-title", + ) + + message = str(exc_info.value) + assert "Fix private prompt" not in message + assert "sources/github/private-org/private-repo" not in message + assert "private-title" not in message + assert "hunter2" not in message + assert "[REDACTED]" in message + + +def test_list_sources_malformed_response_is_redacted() -> None: + """Source list payloads must contain a list of valid Source objects.""" + connector = JulesConnector(api_key="test-key") + connector.get = MagicMock( + return_value=_response( + { + "sources": [ + { + "name": "sources/github/org/repo", + "debug": "private-page-token", + "authorization": "Bearer raw_token", + } + ] + } + ) + ) + + with pytest.raises(JulesError, match="Unexpected Jules response for list_sources") as exc_info: + connector.list_sources(page_token="private-page-token") + + message = str(exc_info.value) + assert "private-page-token" not in message + assert "raw_token" not in message + assert "[REDACTED]" in message + + +def test_list_sessions_requires_sessions_list() -> None: + """Session list payloads should fail loudly when the list contract changes.""" + connector = JulesConnector(api_key="test-key") + connector.get = MagicMock( + return_value=_response( + { + "sessions": { + "name": "sessions/private-session", + "password": "hunter2", + } + } + ) + ) + + with pytest.raises(JulesError, match="Unexpected Jules response for list_sessions") as exc_info: + connector.list_sessions(page_token="private-page-token") + + message = str(exc_info.value) + assert "private-page-token" not in message + assert "hunter2" not in message + assert "[REDACTED]" in message diff --git a/tests/connectors/test_google_services.py b/tests/connectors/test_google_services.py index ac791bf..d88d648 100644 --- a/tests/connectors/test_google_services.py +++ b/tests/connectors/test_google_services.py @@ -1,3 +1,4 @@ +# ruff: noqa: I001 """Tests for Google Cloud services discovery operations.""" from __future__ import annotations @@ -6,7 +7,16 @@ import pytest -from extended_data.connectors.google import GoogleConnectorFull +pytest.importorskip("google.oauth2.service_account") +pytest.importorskip("googleapiclient") + +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString, extend_data +from extended_data.connectors.google import GoogleConnector + + +def _logged_text(logger: MagicMock) -> str: + """Return concatenated mock logger messages.""" + return "\n".join(str(arg) for call in logger.method_calls for arg in call.args) @pytest.fixture @@ -20,7 +30,7 @@ def google_connector(): "project_id": "test-project", } with patch("googleapiclient.discovery.build"): - connector = GoogleConnectorFull(service_account_info=service_account) + connector = GoogleConnector(service_account_info=service_account) connector.logger = MagicMock() return connector @@ -47,6 +57,9 @@ def test_list_compute_instances_all_zones(self, google_connector): result = google_connector.list_compute_instances("test-project") + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) + assert isinstance(result[0]["name"], ExtendedString) assert len(result) == 3 assert result[0]["name"] == "instance-1" assert result[2]["name"] == "instance-3" @@ -65,6 +78,8 @@ def test_list_compute_instances_specific_zone(self, google_connector): result = google_connector.list_compute_instances("test-project", zone="us-central1-a") + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) assert len(result) == 2 mock_instances.list.assert_called_once() @@ -85,6 +100,8 @@ def test_list_compute_instances_pagination(self, google_connector): result = google_connector.list_compute_instances("test-project") + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) assert len(result) == 2 assert mock_instances.aggregatedList.return_value.execute.call_count == 2 @@ -106,6 +123,9 @@ def test_list_gke_clusters(self, google_connector): result = google_connector.list_gke_clusters("test-project") + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) + assert isinstance(result[0]["name"], ExtendedString) assert len(result) == 2 assert result[0]["name"] == "cluster-1" @@ -120,6 +140,8 @@ def test_list_gke_clusters_with_location(self, google_connector): result = google_connector.list_gke_clusters("test-project", location="us-central1") + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) assert len(result) == 1 mock_clusters.list.assert_called_once_with(parent="projects/test-project/locations/us-central1") @@ -136,6 +158,8 @@ def test_get_gke_cluster(self, google_connector): result = google_connector.get_gke_cluster("test-project", "us-central1", "cluster-1") + assert isinstance(result, ExtendedDict) + assert isinstance(result["name"], ExtendedString) assert result["name"] == "cluster-1" assert result["status"] == "RUNNING" @@ -173,6 +197,9 @@ def test_list_storage_buckets(self, google_connector): result = google_connector.list_storage_buckets("test-project") + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) + assert isinstance(result[0]["name"], ExtendedString) assert len(result) == 2 assert result[0]["name"] == "bucket-1" @@ -194,6 +221,9 @@ def test_list_sql_instances(self, google_connector): result = google_connector.list_sql_instances("test-project") + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) + assert isinstance(result[0]["databaseVersion"], ExtendedString) assert len(result) == 2 assert result[0]["databaseVersion"] == "MYSQL_8_0" @@ -215,6 +245,9 @@ def test_list_pubsub_topics(self, google_connector): result = google_connector.list_pubsub_topics("test-project") + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) + assert isinstance(result[0]["name"], ExtendedString) assert len(result) == 2 assert "topic-1" in result[0]["name"] @@ -232,6 +265,9 @@ def test_list_pubsub_subscriptions(self, google_connector): result = google_connector.list_pubsub_subscriptions("test-project") + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) + assert isinstance(result[0]["name"], ExtendedString) assert len(result) == 2 assert "sub-1" in result[0]["name"] @@ -253,6 +289,9 @@ def test_list_kms_keyrings(self, google_connector): result = google_connector.list_kms_keyrings("test-project", "us") + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) + assert isinstance(result[0]["name"], ExtendedString) assert len(result) == 2 assert "keyring-1" in result[0]["name"] @@ -267,6 +306,8 @@ def test_create_kms_keyring(self, google_connector): result = google_connector.create_kms_keyring("test-project", "us", "new-keyring") + assert isinstance(result, ExtendedDict) + assert isinstance(result["name"], ExtendedString) assert "new-keyring" in result["name"] def test_create_kms_key(self, google_connector): @@ -283,4 +324,204 @@ def test_create_kms_key(self, google_connector): result = google_connector.create_kms_key("test-project", "us", "kr1", "new-key") + assert isinstance(result, ExtendedDict) + assert isinstance(result["name"], ExtendedString) assert "new-key" in result["name"] + + def test_create_kms_key_logs_redact_identifiers_but_preserve_call_args(self, google_connector): + """KMS mutation logs should not expose project/key resource identifiers.""" + mock_service = MagicMock() + mock_projects = mock_service.projects.return_value + mock_locations = mock_projects.locations.return_value + mock_keyrings = mock_locations.keyRings.return_value + mock_keys = mock_keyrings.cryptoKeys.return_value + mock_keys.create.return_value.execute.return_value = { + "name": "projects/sensitive-project/locations/us/keyRings/private-ring/cryptoKeys/private-key" + } + google_connector.get_cloudkms_service = MagicMock(return_value=mock_service) + + google_connector.create_kms_key("sensitive-project", "us", "private-ring", "private-key") + + assert mock_keys.create.call_args.kwargs["parent"] == ( + "projects/sensitive-project/locations/us/keyRings/private-ring" + ) + assert mock_keys.create.call_args.kwargs["cryptoKeyId"] == "private-key" + logs = _logged_text(google_connector.logger) + assert "[REDACTED]" in logs + assert "sensitive-project" not in logs + assert "private-ring" not in logs + assert "private-key" not in logs + + +class TestServiceUsage: + """Tests for Service Usage operations.""" + + def test_list_enabled_services(self, google_connector): + """Test listing enabled APIs.""" + mock_service = MagicMock() + mock_services = mock_service.services.return_value + mock_services.list.return_value.execute.return_value = { + "services": [ + {"name": "projects/test-project/services/compute.googleapis.com"}, + {"name": "projects/test-project/services/container.googleapis.com"}, + ] + } + google_connector.get_serviceusage_service = MagicMock(return_value=mock_service) + + result = google_connector.list_enabled_services("test-project") + + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) + assert isinstance(result[0]["name"], ExtendedString) + assert len(result) == 2 + + def test_enable_service(self, google_connector): + """Test enabling an API.""" + mock_service = MagicMock() + mock_services = mock_service.services.return_value + mock_services.enable.return_value.execute.return_value = {"name": "operations/enable-compute"} + google_connector.get_serviceusage_service = MagicMock(return_value=mock_service) + + result = google_connector.enable_service("test-project", "compute.googleapis.com") + + assert isinstance(result, ExtendedDict) + assert isinstance(result["name"], ExtendedString) + assert result["name"] == "operations/enable-compute" + + def test_enable_service_logs_redact_identifiers_but_preserve_call_args(self, google_connector): + """Service Usage logs should not expose project or service names.""" + mock_service = MagicMock() + mock_services = mock_service.services.return_value + mock_services.enable.return_value.execute.return_value = {"name": "operations/enable-private"} + google_connector.get_serviceusage_service = MagicMock(return_value=mock_service) + + google_connector.enable_service("sensitive-project", "private.googleapis.com") + + assert mock_services.enable.call_args.kwargs["name"] == ( + "projects/sensitive-project/services/private.googleapis.com" + ) + logs = _logged_text(google_connector.logger) + assert "[REDACTED]" in logs + assert "sensitive-project" not in logs + assert "private.googleapis.com" not in logs + + def test_disable_service(self, google_connector): + """Test disabling an API.""" + mock_service = MagicMock() + mock_services = mock_service.services.return_value + mock_services.disable.return_value.execute.return_value = {"name": "operations/disable-compute"} + google_connector.get_serviceusage_service = MagicMock(return_value=mock_service) + + result = google_connector.disable_service("test-project", "compute.googleapis.com", force=True) + + assert isinstance(result, ExtendedDict) + assert isinstance(result["name"], ExtendedString) + assert result["name"] == "operations/disable-compute" + mock_services.disable.assert_called_once_with( + name="projects/test-project/services/compute.googleapis.com", + body={"disableDependentServices": True}, + ) + + def test_batch_enable_services(self, google_connector): + """Test enabling multiple APIs.""" + mock_service = MagicMock() + mock_services = mock_service.services.return_value + mock_services.batchEnable.return_value.execute.return_value = {"name": "operations/batch-enable"} + google_connector.get_serviceusage_service = MagicMock(return_value=mock_service) + + result = google_connector.batch_enable_services( + "test-project", + ["compute.googleapis.com", "container.googleapis.com"], + ) + + assert isinstance(result, ExtendedDict) + assert isinstance(result["name"], ExtendedString) + assert result["name"] == "operations/batch-enable" + + +class TestProjectResourceSummary: + """Tests for derived project resource operations.""" + + def test_is_project_empty_denied_check_logs_redact_project_and_error(self, google_connector): + """Denied resource checks should not expose project IDs or raw provider details.""" + denied = RuntimeError("denied sensitive-project token=raw-token") + denied.resp = MagicMock(status=403) # type: ignore[attr-defined] + google_connector.list_compute_instances = MagicMock(side_effect=denied) + + result = google_connector.is_project_empty( + "sensitive-project", + check_gke=False, + check_storage=False, + check_sql=False, + check_pubsub=False, + ) + + assert result is True + logs = _logged_text(google_connector.logger) + assert "[REDACTED]" in logs + assert "sensitive-project" not in logs + assert "raw-token" not in logs + + def test_get_project_iam_users(self, google_connector): + """Test deriving IAM members from a project policy.""" + mock_service = MagicMock() + mock_projects = mock_service.projects.return_value + mock_projects.getIamPolicy.return_value.execute.return_value = { + "bindings": [ + {"role": "roles/viewer", "members": ["user:a@example.com"]}, + {"role": "roles/editor", "members": ["user:a@example.com", "group:dev@example.com"]}, + ] + } + google_connector.get_cloud_resource_manager_service = MagicMock(return_value=mock_service) + + result = google_connector.get_project_iam_users("test-project") + + assert isinstance(result, ExtendedDict) + assert isinstance(result["user:a@example.com"], ExtendedDict) + assert isinstance(result["user:a@example.com"]["roles"], ExtendedList) + assert result["user:a@example.com"]["roles"] == ["roles/viewer", "roles/editor"] + + def test_get_pubsub_resources_for_project(self, google_connector): + """Test aggregating Pub/Sub resources.""" + google_connector.list_pubsub_topics = MagicMock( + return_value=extend_data([{"name": "projects/test-project/topics/topic-1"}]) + ) + google_connector.list_pubsub_subscriptions = MagicMock( + return_value=extend_data([{"name": "projects/test-project/subscriptions/sub-1"}]) + ) + + result = google_connector.get_pubsub_resources_for_project("test-project") + + assert isinstance(result, ExtendedDict) + assert isinstance(result["topics"], ExtendedList) + assert isinstance(result["topics"][0], ExtendedDict) + assert isinstance(result["subscriptions"], ExtendedList) + assert result["topic_count"] == 1 + assert result["subscription_count"] == 1 + + def test_find_inactive_projects(self, google_connector): + """Test finding inactive projects from supplied project metadata.""" + projects = extend_data( + { + "active-project": { + "projectId": "active-project", + "lifecycleState": "ACTIVE", + "updateTime": "2026-06-01T00:00:00Z", + }, + "deleted-project": { + "projectId": "deleted-project", + "lifecycleState": "DELETE_REQUESTED", + }, + } + ) + google_connector.is_project_empty = MagicMock(return_value=True) + + result = google_connector.find_inactive_projects( + projects=projects, + days_since_activity=30, + ) + + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) + assert result[0]["projectId"] == "deleted-project" + assert result[0]["inactive_reason"] == "lifecycle_state=DELETE_REQUESTED" diff --git a/tests/connectors/test_google_tools.py b/tests/connectors/test_google_tools.py index 17aa80c..65d83c2 100644 --- a/tests/connectors/test_google_tools.py +++ b/tests/connectors/test_google_tools.py @@ -2,13 +2,28 @@ from __future__ import annotations +import importlib.util + from unittest.mock import MagicMock, patch import pytest +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString, extend_data + + +# Patch where the tool functions instantiate the first-class connector. +GOOGLE_CONNECTOR_PATCH = "extended_data.connectors.google.GoogleConnector" + + +def test_google_connector_requires_google_sdk_when_constructed_without_extra() -> None: + """Google tool metadata imports without Google SDKs, but the connector still requires the extra.""" + if importlib.util.find_spec("googleapiclient") is not None: + pytest.skip("google-api-python-client is installed") + + from extended_data.connectors.google import GoogleConnector -# Patch target for GoogleConnectorFull - must patch where it's imported -GOOGLE_CONNECTOR_PATCH = "extended_data.connectors.google.GoogleConnectorFull" + with pytest.raises(ImportError, match=r"extended-data\[google\]"): + GoogleConnector(service_account_info={"type": "service_account"}, from_environment=False) class TestGoogleToolDefinitions: @@ -53,24 +68,29 @@ def test_list_projects_basic(self, mock_connector_class): from extended_data.connectors.google.tools import list_projects mock_connector = MagicMock() - mock_connector.list_projects.return_value = [ - { - "projectId": "my-project-123", - "displayName": "My Project", - "state": "ACTIVE", - "parent": "organizations/123456", - }, - { - "projectId": "another-project-456", - "name": "projects/another-project-456", - "state": "ACTIVE", - "parent": "folders/789", - }, - ] + mock_connector.list_projects.return_value = extend_data( + [ + { + "projectId": "my-project-123", + "displayName": "My Project", + "state": "ACTIVE", + "parent": "organizations/123456", + }, + { + "projectId": "another-project-456", + "name": "projects/another-project-456", + "state": "ACTIVE", + "parent": "folders/789", + }, + ] + ) mock_connector_class.return_value = mock_connector result = list_projects() + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) + assert isinstance(result[0]["project_id"], ExtendedString) assert len(result) == 2 assert result[0]["project_id"] == "my-project-123" assert result[0]["name"] == "My Project" @@ -107,6 +127,36 @@ def test_list_projects_max_results(self, mock_connector_class): assert len(result) == 50 +class TestListFolders: + """Tests for list_folders tool.""" + + @patch(GOOGLE_CONNECTOR_PATCH) + def test_list_folders_basic(self, mock_connector_class): + """Test basic list_folders functionality.""" + from extended_data.connectors.google.tools import list_folders + + mock_connector = MagicMock() + mock_connector.list_folders.return_value = extend_data( + [ + { + "name": "folders/123", + "displayName": "Engineering", + "state": "ACTIVE", + "parent": "organizations/456", + } + ] + ) + mock_connector_class.return_value = mock_connector + + result = list_folders(parent="organizations/456") + + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) + assert isinstance(result[0]["display_name"], ExtendedString) + assert result[0]["name"] == "folders/123" + assert result[0]["display_name"] == "Engineering" + + class TestListEnabledServices: """Tests for list_enabled_services tool.""" @@ -116,22 +166,27 @@ def test_list_enabled_services_basic(self, mock_connector_class): from extended_data.connectors.google.tools import list_enabled_services mock_connector = MagicMock() - mock_connector.list_enabled_services.return_value = [ - { - "name": "projects/123/services/compute.googleapis.com", - "config": {"title": "Compute Engine API"}, - "state": "ENABLED", - }, - { - "name": "projects/123/services/storage.googleapis.com", - "config": {"title": "Cloud Storage API"}, - "state": "ENABLED", - }, - ] + mock_connector.list_enabled_services.return_value = extend_data( + [ + { + "name": "projects/123/services/compute.googleapis.com", + "config": {"title": "Compute Engine API"}, + "state": "ENABLED", + }, + { + "name": "projects/123/services/storage.googleapis.com", + "config": {"title": "Cloud Storage API"}, + "state": "ENABLED", + }, + ] + ) mock_connector_class.return_value = mock_connector result = list_enabled_services(project_id="my-project") + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) + assert isinstance(result[0]["title"], ExtendedString) assert len(result) == 2 assert result[0]["name"] == "projects/123/services/compute.googleapis.com" assert result[0]["title"] == "Compute Engine API" @@ -160,24 +215,29 @@ def test_list_billing_accounts_basic(self, mock_connector_class): from extended_data.connectors.google.tools import list_billing_accounts mock_connector = MagicMock() - mock_connector.list_billing_accounts.return_value = [ - { - "name": "billingAccounts/012345-6789AB-CDEF01", - "displayName": "My Billing Account", - "open": True, - "masterBillingAccount": "", - }, - { - "name": "billingAccounts/ABCDEF-123456-789012", - "displayName": "Another Billing", - "open": False, - "masterBillingAccount": "billingAccounts/012345-6789AB-CDEF01", - }, - ] + mock_connector.list_billing_accounts.return_value = extend_data( + [ + { + "name": "billingAccounts/012345-6789AB-CDEF01", + "displayName": "My Billing Account", + "open": True, + "masterBillingAccount": "", + }, + { + "name": "billingAccounts/ABCDEF-123456-789012", + "displayName": "Another Billing", + "open": False, + "masterBillingAccount": "billingAccounts/012345-6789AB-CDEF01", + }, + ] + ) mock_connector_class.return_value = mock_connector result = list_billing_accounts() + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) + assert isinstance(result[0]["display_name"], ExtendedString) assert len(result) == 2 assert "billingAccounts/" in result[0]["name"] assert result[0]["display_name"] == "My Billing Account" @@ -207,26 +267,31 @@ def test_list_workspace_users_basic(self, mock_connector_class): from extended_data.connectors.google.tools import list_workspace_users mock_connector = MagicMock() - mock_connector.list_users.return_value = [ - { - "primaryEmail": "john.doe@example.com", - "name": {"fullName": "John Doe"}, - "full_name": "John Doe", - "suspended": False, - "orgUnitPath": "/", - }, + mock_connector.list_users.return_value = extend_data( { - "primaryEmail": "jane.smith@example.com", - "name": {"fullName": "Jane Smith"}, - "full_name": "Jane Smith", - "suspended": False, - "orgUnitPath": "/Engineering", - }, - ] + "john.doe@example.com": { + "primaryEmail": "john.doe@example.com", + "name": {"fullName": "John Doe"}, + "full_name": "John Doe", + "suspended": False, + "orgUnitPath": "/", + }, + "jane.smith@example.com": { + "primaryEmail": "jane.smith@example.com", + "name": {"fullName": "Jane Smith"}, + "full_name": "Jane Smith", + "suspended": False, + "orgUnitPath": "/Engineering", + }, + } + ) mock_connector_class.return_value = mock_connector result = list_workspace_users() + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) + assert isinstance(result[0]["email"], ExtendedString) assert len(result) == 2 assert result[0]["email"] == "john.doe@example.com" assert result[0]["full_name"] == "John Doe" @@ -270,6 +335,8 @@ def test_list_workspace_users_suspended(self, mock_connector_class): result = list_workspace_users() + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) assert len(result) == 1 assert result[0]["suspended"] is True @@ -283,24 +350,29 @@ def test_list_workspace_groups_basic(self, mock_connector_class): from extended_data.connectors.google.tools import list_workspace_groups mock_connector = MagicMock() - mock_connector.list_groups.return_value = [ - { - "email": "admins@example.com", - "name": "Admins", - "description": "Administrator group", - "directMembersCount": 5, - }, + mock_connector.list_groups.return_value = extend_data( { - "email": "developers@example.com", - "name": "Developers", - "description": "Development team", - "directMembersCount": 25, - }, - ] + "admins@example.com": { + "email": "admins@example.com", + "name": "Admins", + "description": "Administrator group", + "directMembersCount": 5, + }, + "developers@example.com": { + "email": "developers@example.com", + "name": "Developers", + "description": "Development team", + "directMembersCount": 25, + }, + } + ) mock_connector_class.return_value = mock_connector result = list_workspace_groups() + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) + assert isinstance(result[0]["email"], ExtendedString) assert len(result) == 2 assert result[0]["email"] == "admins@example.com" assert result[0]["name"] == "Admins" @@ -342,6 +414,8 @@ def test_list_workspace_groups_empty_description(self, mock_connector_class): result = list_workspace_groups() + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) assert len(result) == 1 assert result[0]["description"] == "" assert result[0]["direct_members_count"] == 0 @@ -377,13 +451,12 @@ def test_get_tools_invalid_framework(self): with pytest.raises(ValueError, match="Unknown framework"): get_tools(framework="invalid") - def test_get_tools_strands_alias(self): - """Test 'functions' is an alias for 'strands'.""" + def test_get_tools_rejects_functions_alias(self): + """Plain-function tools should use the canonical strands framework name.""" from extended_data.connectors.google.tools import get_tools - tools = get_tools(framework="functions") - assert len(tools) == 6 - assert all(callable(t) for t in tools) + with pytest.raises(ValueError, match="Unknown framework"): + get_tools(framework="functions") def test_all_exports_exist(self): """Test that all expected exports are available.""" diff --git a/tests/connectors/test_google_workspace.py b/tests/connectors/test_google_workspace.py index f606ca1..adfd08b 100644 --- a/tests/connectors/test_google_workspace.py +++ b/tests/connectors/test_google_workspace.py @@ -1,3 +1,4 @@ +# ruff: noqa: I001 """Tests for Google Workspace (Admin Directory) operations.""" from __future__ import annotations @@ -6,7 +7,25 @@ import pytest -from extended_data.connectors.google import GoogleConnectorFull +pytest.importorskip("google.oauth2.service_account") +pytest.importorskip("googleapiclient") + +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString, extend_data +from extended_data.connectors.google import GoogleConnector + + +def _logged_text(logger: MagicMock) -> str: + """Return concatenated mock logger messages.""" + return "\n".join(str(arg) for call in logger.method_calls for arg in call.args) + + +def _http_error(status: int): + """Return a Google API HttpError with the requested status.""" + from googleapiclient.errors import HttpError + + response = MagicMock() + response.status = status + return HttpError(response, b"Google API error") @pytest.fixture @@ -20,7 +39,7 @@ def google_connector(): "project_id": "test-project", } with patch("googleapiclient.discovery.build"): - connector = GoogleConnectorFull(service_account_info=service_account) + connector = GoogleConnector(service_account_info=service_account) connector.logger = MagicMock() return connector @@ -42,6 +61,9 @@ def test_list_users(self, google_connector): result = google_connector.list_users() + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) + assert isinstance(result[0]["primaryEmail"], ExtendedString) assert len(result) == 2 assert result[0]["primaryEmail"] == "user1@example.com" @@ -54,6 +76,8 @@ def test_list_users_with_domain(self, google_connector): result = google_connector.list_users(domain="example.com") + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) assert len(result) == 1 call_args = mock_users.list.call_args[1] assert call_args["domain"] == "example.com" @@ -75,9 +99,39 @@ def test_list_users_pagination(self, google_connector): result = google_connector.list_users() + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) assert len(result) == 2 assert mock_users.list.return_value.execute.call_count == 2 + def test_list_workspace_users_unhumps_and_uses_subject(self, google_connector): + """Legacy Workspace user listing should still promote and unhump payloads.""" + mock_service = MagicMock() + mock_users = mock_service.users.return_value + mock_users.list.return_value.execute.side_effect = [ + { + "users": [{"primaryEmail": "user1@example.com", "orgUnitPath": "/Engineering"}], + "nextPageToken": "next", + }, + {"users": [{"primaryEmail": "user2@example.com", "orgUnitPath": "/Sales"}]}, + ] + google_connector.get_admin_directory_service = MagicMock(return_value=mock_service) + + result = google_connector.list_workspace_users( + domain="example.com", + max_results=100, + unhump_users=True, + subject="admin@example.com", + ) + + assert isinstance(result, ExtendedList) + assert result[0]["primary_email"] == "user1@example.com" + assert result[0]["org_unit_path"] == "/Engineering" + google_connector.get_admin_directory_service.assert_called_once_with(subject="admin@example.com") + first_call, second_call = mock_users.list.call_args_list + assert first_call.kwargs == {"customer": "my_customer", "maxResults": 100, "domain": "example.com"} + assert second_call.kwargs["pageToken"] == "next" + def test_get_user(self, google_connector): """Test getting a specific user.""" mock_service = MagicMock() @@ -91,6 +145,8 @@ def test_get_user(self, google_connector): result = google_connector.get_user("user1@example.com") + assert isinstance(result, ExtendedDict) + assert isinstance(result["primaryEmail"], ExtendedString) assert result["primaryEmail"] == "user1@example.com" assert result["suspended"] is False @@ -125,10 +181,16 @@ def test_create_user(self, google_connector): given_name="New", family_name="User", password="SecurePass123!", + customSchemas=extend_data({"HR": {"level": "5"}}), ) + assert isinstance(result, ExtendedDict) + assert isinstance(result["primaryEmail"], ExtendedString) assert result["primaryEmail"] == "newuser@example.com" mock_users.insert.assert_called_once() + body = mock_users.insert.call_args.kwargs["body"] + assert isinstance(body, dict) + assert isinstance(body["customSchemas"], dict) def test_update_user(self, google_connector): """Test updating a user.""" @@ -140,9 +202,34 @@ def test_update_user(self, google_connector): } google_connector.get_admin_directory_service = MagicMock(return_value=mock_service) - result = google_connector.update_user("user1@example.com", suspended=True) + result = google_connector.update_user( + "user1@example.com", + suspended=True, + customSchemas=extend_data({"HR": {"level": "7"}}), + ) + assert isinstance(result, ExtendedDict) assert result["suspended"] is True + body = mock_users.update.call_args.kwargs["body"] + assert isinstance(body, dict) + assert isinstance(body["customSchemas"], dict) + + def test_update_user_logs_redact_identifier_but_preserve_call_args(self, google_connector): + """Workspace user mutation logs should not expose user keys.""" + mock_service = MagicMock() + mock_users = mock_service.users.return_value + mock_users.update.return_value.execute.return_value = { + "primaryEmail": "sensitive.user@example.com", + "suspended": True, + } + google_connector.get_admin_directory_service = MagicMock(return_value=mock_service) + + google_connector.update_user("sensitive.user@example.com", suspended=True) + + assert mock_users.update.call_args.kwargs["userKey"] == "sensitive.user@example.com" + logs = _logged_text(google_connector.logger) + assert "[REDACTED]" in logs + assert "sensitive.user@example.com" not in logs def test_delete_user(self, google_connector): """Test deleting a user.""" @@ -155,6 +242,87 @@ def test_delete_user(self, google_connector): mock_users.delete.assert_called_once_with(userKey="user1@example.com") + def test_create_or_update_user_returns_existing_when_updates_disabled(self, google_connector): + """Idempotent user creation should return existing users without mutation by default.""" + mock_service = MagicMock() + mock_users = mock_service.users.return_value + mock_users.get.return_value.execute.return_value = {"primaryEmail": "existing@example.com"} + google_connector.get_admin_directory_service = MagicMock(return_value=mock_service) + + result = google_connector.create_or_update_user( + primary_email="existing@example.com", + given_name="Existing", + family_name="User", + password="SecurePass123!", + ) + + assert isinstance(result, ExtendedDict) + assert result["primaryEmail"] == "existing@example.com" + mock_users.update.assert_not_called() + mock_users.insert.assert_not_called() + logs = _logged_text(google_connector.logger) + assert "[REDACTED]" in logs + assert "existing@example.com" not in logs + + def test_create_or_update_user_updates_existing_with_builtin_body(self, google_connector): + """Idempotent user creation should lower extended update payloads before SDK calls.""" + mock_service = MagicMock() + mock_users = mock_service.users.return_value + mock_users.get.return_value.execute.return_value = {"primaryEmail": "existing@example.com"} + mock_users.update.return_value.execute.return_value = {"primaryEmail": "existing@example.com", "updated": True} + google_connector.get_admin_directory_service = MagicMock(return_value=mock_service) + + result = google_connector.create_or_update_user( + primary_email="existing@example.com", + given_name="Existing", + family_name="User", + password="SecurePass123!", + update_if_exists=True, + customSchemas=extend_data({"HR": {"level": "5"}}), + ) + + assert isinstance(result, ExtendedDict) + assert result["updated"] is True + mock_users.insert.assert_not_called() + body = mock_users.update.call_args.kwargs["body"] + assert isinstance(body, dict) + assert isinstance(body["customSchemas"], dict) + assert body["customSchemas"] == {"HR": {"level": "5"}} + + def test_create_or_update_user_creates_when_missing(self, google_connector): + """Idempotent user creation should insert when the user is not found.""" + mock_service = MagicMock() + mock_users = mock_service.users.return_value + mock_users.get.return_value.execute.side_effect = _http_error(404) + mock_users.insert.return_value.execute.return_value = {"primaryEmail": "newuser@example.com"} + google_connector.get_admin_directory_service = MagicMock(return_value=mock_service) + + result = google_connector.create_or_update_user( + primary_email="newuser@example.com", + given_name="New", + family_name="User", + password="SecurePass123!", + ) + + assert isinstance(result, ExtendedDict) + assert result["primaryEmail"] == "newuser@example.com" + mock_users.insert.assert_called_once() + + def test_create_or_update_user_reraises_non_not_found_errors(self, google_connector): + """Idempotent user creation should not mask unexpected Google API errors.""" + mock_service = MagicMock() + mock_users = mock_service.users.return_value + mock_users.get.return_value.execute.side_effect = _http_error(403) + google_connector.get_admin_directory_service = MagicMock(return_value=mock_service) + + with pytest.raises(Exception, match="Google API error"): + google_connector.create_or_update_user( + primary_email="blocked@example.com", + given_name="Blocked", + family_name="User", + password="SecurePass123!", + ) + class TestWorkspaceGroups: """Tests for Workspace group operations.""" @@ -173,6 +341,9 @@ def test_list_groups(self, google_connector): result = google_connector.list_groups() + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) + assert isinstance(result[0]["email"], ExtendedString) assert len(result) == 2 assert result[0]["email"] == "group1@example.com" @@ -185,10 +356,39 @@ def test_list_groups_with_domain(self, google_connector): result = google_connector.list_groups(domain="example.com") + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) assert len(result) == 1 call_args = mock_groups.list.call_args[1] assert call_args["domain"] == "example.com" + def test_list_workspace_groups_unhumps_and_uses_subject(self, google_connector): + """Legacy Workspace group listing should still promote and unhump payloads.""" + mock_service = MagicMock() + mock_groups = mock_service.groups.return_value + mock_groups.list.return_value.execute.side_effect = [ + { + "groups": [{"email": "group1@example.com", "directMembersCount": "5"}], + "nextPageToken": "next", + }, + {"groups": [{"email": "group2@example.com", "directMembersCount": "2"}]}, + ] + google_connector.get_admin_directory_service = MagicMock(return_value=mock_service) + + result = google_connector.list_workspace_groups( + domain="example.com", + max_results=50, + unhump_groups=True, + subject="admin@example.com", + ) + + assert isinstance(result, ExtendedList) + assert result[0]["direct_members_count"] == "5" + google_connector.get_admin_directory_service.assert_called_once_with(subject="admin@example.com") + first_call, second_call = mock_groups.list.call_args_list + assert first_call.kwargs == {"customer": "my_customer", "maxResults": 50, "domain": "example.com"} + assert second_call.kwargs["pageToken"] == "next" + def test_get_group(self, google_connector): """Test getting a specific group.""" mock_service = MagicMock() @@ -202,6 +402,8 @@ def test_get_group(self, google_connector): result = google_connector.get_group("group1@example.com") + assert isinstance(result, ExtendedDict) + assert isinstance(result["email"], ExtendedString) assert result["email"] == "group1@example.com" assert result["directMembersCount"] == "5" @@ -236,6 +438,8 @@ def test_create_group(self, google_connector): name="New Group", ) + assert isinstance(result, ExtendedDict) + assert isinstance(result["email"], ExtendedString) assert result["email"] == "newgroup@example.com" def test_list_group_members(self, google_connector): @@ -252,6 +456,9 @@ def test_list_group_members(self, google_connector): result = google_connector.list_group_members("group1@example.com") + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) + assert isinstance(result[1]["role"], ExtendedString) assert len(result) == 2 assert result[1]["role"] == "OWNER" @@ -267,8 +474,29 @@ def test_add_group_member(self, google_connector): result = google_connector.add_group_member("group1@example.com", "user1@example.com") + assert isinstance(result, ExtendedDict) + assert isinstance(result["email"], ExtendedString) assert result["email"] == "user1@example.com" + def test_add_group_member_logs_redact_identifiers_but_preserve_call_args(self, google_connector): + """Workspace membership logs should not expose member or group keys.""" + mock_service = MagicMock() + mock_members = mock_service.members.return_value + mock_members.insert.return_value.execute.return_value = { + "email": "sensitive.user@example.com", + "role": "MEMBER", + } + google_connector.get_admin_directory_service = MagicMock(return_value=mock_service) + + google_connector.add_group_member("private-group@example.com", "sensitive.user@example.com") + + assert mock_members.insert.call_args.kwargs["groupKey"] == "private-group@example.com" + assert mock_members.insert.call_args.kwargs["body"]["email"] == "sensitive.user@example.com" + logs = _logged_text(google_connector.logger) + assert "[REDACTED]" in logs + assert "private-group@example.com" not in logs + assert "sensitive.user@example.com" not in logs + def test_remove_group_member(self, google_connector): """Test removing a member from a group.""" mock_service = MagicMock() @@ -280,6 +508,75 @@ def test_remove_group_member(self, google_connector): mock_members.delete.assert_called_once() + def test_create_or_update_group_returns_existing_when_updates_disabled(self, google_connector): + """Idempotent group creation should return existing groups without mutation by default.""" + mock_service = MagicMock() + mock_groups = mock_service.groups.return_value + mock_groups.get.return_value.execute.return_value = {"email": "existing-group@example.com"} + google_connector.get_admin_directory_service = MagicMock(return_value=mock_service) + + result = google_connector.create_or_update_group( + email="existing-group@example.com", + name="Existing Group", + ) + + assert isinstance(result, ExtendedDict) + assert result["email"] == "existing-group@example.com" + mock_groups.update.assert_not_called() + mock_groups.insert.assert_not_called() + + def test_create_or_update_group_updates_existing_with_additional_fields(self, google_connector): + """Idempotent group creation should lower extended group payloads before SDK calls.""" + mock_service = MagicMock() + mock_groups = mock_service.groups.return_value + mock_groups.get.return_value.execute.return_value = {"email": "existing-group@example.com"} + mock_groups.update.return_value.execute.return_value = {"email": "existing-group@example.com", "updated": True} + google_connector.get_admin_directory_service = MagicMock(return_value=mock_service) + + result = google_connector.create_or_update_group( + email="existing-group@example.com", + name="Existing Group", + update_if_exists=True, + settings=extend_data({"whoCanPostMessage": "ALL_MEMBERS_CAN_POST"}), + ) + + assert isinstance(result, ExtendedDict) + assert result["updated"] is True + mock_groups.insert.assert_not_called() + body = mock_groups.update.call_args.kwargs["body"] + assert isinstance(body, dict) + assert body["settings"] == {"whoCanPostMessage": "ALL_MEMBERS_CAN_POST"} + + def test_create_or_update_group_creates_when_missing(self, google_connector): + """Idempotent group creation should insert when the group is not found.""" + mock_service = MagicMock() + mock_groups = mock_service.groups.return_value + mock_groups.get.return_value.execute.side_effect = _http_error(404) + mock_groups.insert.return_value.execute.return_value = {"email": "newgroup@example.com"} + google_connector.get_admin_directory_service = MagicMock(return_value=mock_service) + + result = google_connector.create_or_update_group( + email="newgroup@example.com", + name="New Group", + ) + + assert isinstance(result, ExtendedDict) + assert result["email"] == "newgroup@example.com" + mock_groups.insert.assert_called_once() + + def test_create_or_update_group_reraises_non_not_found_errors(self, google_connector): + """Idempotent group creation should not mask unexpected Google API errors.""" + mock_service = MagicMock() + mock_groups = mock_service.groups.return_value + mock_groups.get.return_value.execute.side_effect = _http_error(403) + google_connector.get_admin_directory_service = MagicMock(return_value=mock_service) + + with pytest.raises(Exception, match="Google API error"): + google_connector.create_or_update_group( + email="blocked-group@example.com", + name="Blocked Group", + ) + class TestWorkspaceOrgUnits: """Tests for Workspace organizational unit operations.""" @@ -298,5 +595,122 @@ def test_list_org_units(self, google_connector): result = google_connector.list_org_units() + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) + assert isinstance(result[0]["name"], ExtendedString) assert len(result) == 2 assert result[0]["name"] == "Engineering" + + +class TestWorkspaceLicenses: + """Tests for Workspace license operations.""" + + def test_list_available_licenses_uses_delegated_credentials_and_paginates(self, google_connector): + """License listing should use the licensing scope, subject delegation, and pagination.""" + credentials = MagicMock(name="credentials") + delegated_credentials = MagicMock(name="delegated_credentials") + credentials.with_subject.return_value = delegated_credentials + mock_service = MagicMock() + mock_assignments = mock_service.licenseAssignments.return_value + mock_assignments.listForProduct.return_value.execute.side_effect = [ + { + "items": [{"skuId": "sku-1", "userId": "user1@example.com"}], + "nextPageToken": "next", + }, + {"items": [{"skuId": "sku-2", "userId": "user2@example.com"}]}, + ] + + with ( + patch("google.oauth2.service_account.Credentials.from_service_account_info", return_value=credentials) as from_info, + patch("googleapiclient.discovery.build", return_value=mock_service) as build, + ): + result = google_connector.list_available_licenses( + customer_id="customer-1", + product_id="Google-Apps", + subject="admin@example.com", + ) + + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) + assert result[0]["productId"] == "Google-Apps" + assert result[1]["skuId"] == "sku-2" + from_info.assert_called_once_with( + google_connector.service_account_info, + scopes=["https://www.googleapis.com/auth/apps.licensing"], + ) + credentials.with_subject.assert_called_once_with("admin@example.com") + build.assert_called_once_with( + "licensing", + "v1", + credentials=delegated_credentials, + cache_discovery=False, + ) + first_call, second_call = mock_assignments.listForProduct.call_args_list + assert first_call.kwargs == {"productId": "Google-Apps", "customerId": "customer-1"} + assert second_call.kwargs["pageToken"] == "next" + + def test_list_available_licenses_ignores_unavailable_products(self, google_connector): + """Unavailable or forbidden products should not fail broad license discovery.""" + credentials = MagicMock(name="credentials") + mock_service = MagicMock() + mock_assignments = mock_service.licenseAssignments.return_value + mock_assignments.listForProduct.return_value.execute.side_effect = [ + _http_error(404), + _http_error(403), + {"items": [{"skuId": "sku-1"}]}, + {"items": []}, + {"items": []}, + {"items": []}, + ] + + with ( + patch("google.oauth2.service_account.Credentials.from_service_account_info", return_value=credentials), + patch("googleapiclient.discovery.build", return_value=mock_service), + ): + result = google_connector.list_available_licenses() + + assert isinstance(result, ExtendedList) + assert result[0]["productId"] == "101034" + assert mock_assignments.listForProduct.call_count == 6 + + def test_list_available_licenses_logs_unexpected_product_errors(self, google_connector): + """Unexpected product errors should be logged and redacted without aborting discovery.""" + credentials = MagicMock(name="credentials") + mock_service = MagicMock() + mock_assignments = mock_service.licenseAssignments.return_value + mock_assignments.listForProduct.return_value.execute.side_effect = [_http_error(500)] + + with ( + patch("google.oauth2.service_account.Credentials.from_service_account_info", return_value=credentials), + patch("googleapiclient.discovery.build", return_value=mock_service), + ): + result = google_connector.list_available_licenses(product_id="private-product@example.com") + + assert result == [] + logs = _logged_text(google_connector.logger) + assert "[REDACTED]" in logs + assert "private-product@example.com" not in logs + + def test_get_license_summary_counts_assigned_skus(self, google_connector): + """License summaries should aggregate promoted license payloads by product and SKU.""" + google_connector.list_available_licenses = MagicMock( + return_value=extend_data( + [ + {"productId": "Google-Apps", "skuId": "sku-1"}, + {"productId": "Google-Apps", "skuId": "sku-1"}, + {"productId": "Google-Vault", "skuId": "sku-2"}, + {"skuId": "sku-unknown"}, + ] + ) + ) + + result = google_connector.get_license_summary(customer_id="customer-1", subject="admin@example.com") + + assert isinstance(result, ExtendedDict) + assert result["Google-Apps/sku-1"]["assigned"] == 2 + assert result["Google-Vault/sku-2"]["assigned"] == 1 + assert result["unknown/sku-unknown"]["assigned"] == 1 + google_connector.list_available_licenses.assert_called_once_with( + customer_id="customer-1", + subject="admin@example.com", + ) diff --git a/tests/connectors/test_mcp.py b/tests/connectors/test_mcp.py index 2c2ec02..119d619 100644 --- a/tests/connectors/test_mcp.py +++ b/tests/connectors/test_mcp.py @@ -2,15 +2,295 @@ from __future__ import annotations +import json + +from unittest.mock import patch + import pytest -from extended_data.connectors.mcp import create_server +from extended_data.connectors import mcp as mcp_module +from extended_data.connectors.mcp import ( + _catalog_tool_definitions, + _get_public_methods, + _jsonable_tool_result, + _tool_error_text, + _tool_result_text, + _unknown_tool_text, + create_server, +) +from extended_data.connectors.meshy.connector import MeshyConnector +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedSet + + +class ExampleMCPConnector: + """Tiny connector shell for MCP handler tests.""" + + def fetch(self, enabled: bool = False, count: int = 0) -> ExtendedDict: + """Fetch example MCP data.""" + return ExtendedDict({"enabled": enabled, "count": count, "password": "hunter2"}) -def test_create_server(): +def test_create_server() -> None: """Test that the MCP server can be created and has tools.""" pytest.importorskip("mcp") server = create_server() assert server.name == "extended-data" # Basic check that server was initialized assert server is not None + + +def test_mcp_public_methods_only_include_extended_payload_boundaries() -> None: + """Generic MCP exposure should skip raw clients and inherited base helpers.""" + method_names = {name for name, _ in _get_public_methods(MeshyConnector)} + + assert "text3d_generate" in method_names + assert "image3d_generate" in method_names + assert "request_data" not in method_names + assert "decode_response" not in method_names + assert "get_ai_tool_definitions" not in method_names + assert "freeze_inputs" not in method_names + assert "merge_inputs" not in method_names + assert "replace_inputs" not in method_names + + +def test_catalog_tools_expose_connector_discovery_without_credentials() -> None: + """Generic MCP should expose connector catalog queries as first-class tools.""" + tools = _catalog_tool_definitions() + + expected = { + "extended_data_list_connectors", + "extended_data_list_available_connectors", + "extended_data_list_connector_info", + "extended_data_get_connector_info", + "extended_data_list_connector_categories", + "extended_data_list_connector_capabilities", + "extended_data_list_connectors_by_category", + "extended_data_list_connectors_by_capability", + } + + assert expected <= set(tools) + assert tools["extended_data_get_connector_info"]["parameters"]["required"] == ["name"] + assert tools["extended_data_list_connectors_by_category"]["parameters"]["required"] == ["category"] + assert tools["extended_data_list_connectors_by_capability"]["parameters"]["required"] == ["capability"] + + +def test_catalog_tool_handlers_return_tier2_catalog_payloads() -> None: + """Catalog MCP handlers should reuse the registry's Tier 2 payload surface.""" + tools = _catalog_tool_definitions() + + names = tools["extended_data_list_connectors"]["handler"]() + available_names = tools["extended_data_list_available_connectors"]["handler"]() + github = tools["extended_data_get_connector_info"]["handler"](name="github") + categories = tools["extended_data_list_connector_categories"]["handler"]() + repositories = tools["extended_data_list_connectors_by_capability"]["handler"](capability="repositories") + + assert isinstance(names, ExtendedList) + assert "github" in names + assert isinstance(available_names, ExtendedList) + assert "cursor" in available_names + assert set(available_names) <= set(names) + assert isinstance(github, ExtendedDict) + assert github["category"] == "development" + assert "repositories" in github["capabilities"] + assert isinstance(categories, ExtendedList) + assert "cloud" in categories + assert isinstance(repositories, ExtendedList) + assert "github" in {connector["name"] for connector in repositories} + + +def test_catalog_tool_result_text_uses_shared_export_boundary() -> None: + """Catalog MCP tool output should serialize like connector method output.""" + tools = _catalog_tool_definitions() + payload = tools["extended_data_get_connector_info"]["handler"](name="github") + + text = _tool_result_text(payload) + + assert '"name": "github"' in text + assert '"category": "development"' in text + assert '"capabilities": [' in text + + +def test_jsonable_tool_result_lowers_extended_mapping_payloads() -> None: + """MCP result serialization keeps Tier 2 mapping payloads as JSON objects.""" + payload = ExtendedDict({"service": {"name": "api"}}) + + assert _jsonable_tool_result(payload) == {"service": {"name": "api"}} + + +def test_tool_result_text_uses_shared_export_boundary() -> None: + """MCP text payloads should serialize through the Tier 3 export boundary.""" + payload = ExtendedDict({"service": {"name": "api"}}) + + with patch( + "extended_data.connectors.mcp.wrap_raw_data_for_export", + wraps=mcp_module.wrap_raw_data_for_export, + ) as mock_wrap_for_export: + text = _tool_result_text(payload) + + assert '"service": {' in text + mock_wrap_for_export.assert_called_once_with( + {"service": {"name": "api"}}, + allow_encoding="json", + indent_2=True, + default=str, + ) + + +def test_jsonable_tool_result_redacts_sensitive_mapping_payloads() -> None: + """MCP result serialization should not bypass connector redaction.""" + payload = ExtendedDict({"password": "hunter2", "nested": {"api_key": "key_123"}}) + + assert _jsonable_tool_result(payload) == {"password": "[REDACTED]", "nested": {"api_key": "[REDACTED]"}} + + +def test_jsonable_tool_result_lowers_extended_sequence_payloads() -> None: + """MCP result serialization keeps Tier 2 sequence payloads as JSON arrays.""" + payload = ExtendedList([{"service": "api"}]) + + assert _jsonable_tool_result(payload) == [{"service": "api"}] + + +def test_jsonable_tool_result_redacts_sensitive_sequence_payloads() -> None: + """MCP result serialization should redact secrets inside array payloads.""" + payload = ExtendedList([{"name": "api", "access_token": "tok_123"}, {"message": "client_secret=raw"}]) + + assert _jsonable_tool_result(payload) == [ + {"name": "api", "access_token": "[REDACTED]"}, + {"message": "client_secret=[REDACTED]"}, + ] + + +def test_jsonable_tool_result_lowers_extended_set_payloads() -> None: + """MCP result serialization turns Tier 2 sets into JSON arrays.""" + payload = ExtendedSet({"api", "worker"}) + + assert sorted(_jsonable_tool_result(payload)) == ["api", "worker"] + + +def test_tool_error_text_redacts_sensitive_exception_values() -> None: + """Generic MCP errors should not bypass connector redaction.""" + error = RuntimeError("failed password=hunter2 Authorization: Bearer raw_token") + + text = _tool_error_text(error) + + assert "hunter2" not in text + assert "raw_token" not in text + assert "[REDACTED]" in text + + +def test_tool_error_text_redacts_explicit_argument_values() -> None: + """Generic MCP errors should redact caller-provided resource context.""" + error = RuntimeError("failed for private-user@example.com at /tmp/private%2Fpath while handling Fix login") + + text = _tool_error_text( + error, + values=[ + { + "email": "private-user@example.com", + "metadata": {"path": "/tmp/private/path", "prompt": "Fix login"}, + } + ], + ) + + assert "private-user@example.com" not in text + assert "/tmp/private%2Fpath" not in text + assert "Fix login" not in text + assert text.count("[REDACTED]") >= 3 + + +def test_unknown_tool_text_redacts_sensitive_tool_names() -> None: + """Generic MCP unknown-tool diagnostics should redact user-controlled names.""" + text = _unknown_tool_text("password=hunter2 Authorization: Bearer raw_token") + + assert "hunter2" not in text + assert "raw_token" not in text + assert "[REDACTED]" in text + + +@pytest.mark.asyncio +async def test_create_server_registered_list_tools_handler_exposes_catalog_and_methods() -> None: + """The registered MCP list-tools handler should expose catalog and connector tools.""" + mcp_types = pytest.importorskip("mcp.types") + + with patch("extended_data.connectors.mcp._list_connector_classes", return_value={"example": ExampleMCPConnector}): + server = create_server() + + result = await server.request_handlers[mcp_types.ListToolsRequest](mcp_types.ListToolsRequest()) + tools = {tool.name: tool for tool in result.root.tools} + + assert "extended_data_get_connector_info" in tools + assert tools["extended_data_get_connector_info"].inputSchema["required"] == ["name"] + assert "example_fetch" in tools + assert tools["example_fetch"].description == "Fetch example MCP data." + assert tools["example_fetch"].inputSchema["properties"]["enabled"]["type"] == "boolean" + assert tools["example_fetch"].inputSchema["properties"]["count"]["type"] == "integer" + + +@pytest.mark.asyncio +async def test_create_server_registered_catalog_call_handler_uses_shared_result_boundary() -> None: + """The registered MCP call handler should serialize catalog tool results.""" + mcp_types = pytest.importorskip("mcp.types") + server = create_server() + await server.request_handlers[mcp_types.ListToolsRequest](mcp_types.ListToolsRequest()) + + result = await server.request_handlers[mcp_types.CallToolRequest]( + mcp_types.CallToolRequest( + params=mcp_types.CallToolRequestParams( + name="extended_data_get_connector_info", + arguments={"name": "github"}, + ) + ) + ) + + payload = json.loads(result.root.content[0].text) + assert payload["name"] == "github" + assert payload["category"] == "development" + assert "repositories" in payload["capabilities"] + + +@pytest.mark.asyncio +async def test_create_server_registered_connector_call_handler_redacts_payloads() -> None: + """The registered MCP call handler should dispatch connector methods and redact results.""" + mcp_types = pytest.importorskip("mcp.types") + connector = ExampleMCPConnector() + + with ( + patch("extended_data.connectors.mcp._list_connector_classes", return_value={"example": ExampleMCPConnector}), + patch("extended_data.connectors.mcp.get_connector", return_value=connector) as mock_get_connector, + ): + server = create_server() + await server.request_handlers[mcp_types.ListToolsRequest](mcp_types.ListToolsRequest()) + result = await server.request_handlers[mcp_types.CallToolRequest]( + mcp_types.CallToolRequest( + params=mcp_types.CallToolRequestParams( + name="example_fetch", + arguments={"enabled": True, "count": 3}, + ) + ) + ) + + mock_get_connector.assert_called_once_with("example") + payload = json.loads(result.root.content[0].text) + assert payload == {"enabled": True, "count": 3, "password": "[REDACTED]"} + + +@pytest.mark.asyncio +async def test_create_server_registered_call_handler_redacts_unknown_tools() -> None: + """The registered MCP call handler should sanitize unknown tool diagnostics.""" + mcp_types = pytest.importorskip("mcp.types") + server = create_server() + await server.request_handlers[mcp_types.ListToolsRequest](mcp_types.ListToolsRequest()) + + result = await server.request_handlers[mcp_types.CallToolRequest]( + mcp_types.CallToolRequest( + params=mcp_types.CallToolRequestParams( + name="password=hunter2 Authorization: Bearer raw_token", + arguments={}, + ) + ) + ) + + text = result.root.content[0].text + assert "hunter2" not in text + assert "raw_token" not in text + assert "Unknown tool: password=[REDACTED]" in text diff --git a/tests/connectors/test_optional_dependencies.py b/tests/connectors/test_optional_dependencies.py new file mode 100644 index 0000000..87d365a --- /dev/null +++ b/tests/connectors/test_optional_dependencies.py @@ -0,0 +1,169 @@ +"""Tests for connector optional dependency helpers.""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import pytest +import tomlkit + +from extended_data.connectors import _optional, registry +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString + + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def _pyproject() -> tomlkit.TOMLDocument: + return tomlkit.parse((REPO_ROOT / "pyproject.toml").read_text()) + + +def test_builtin_connector_metadata_maps_stay_aligned() -> None: + """Built-in connector registries should fail fast when metadata drifts.""" + names = set(registry.BUILTIN_CONNECTORS) + + assert names == set(_optional.CONNECTOR_REQUIREMENTS) + assert names == set(_optional.CONNECTOR_EXTRAS) + + for name, spec in registry.BUILTIN_CONNECTORS.items(): + extra = _optional.get_extra_for_connector(name) + assert isinstance(extra, ExtendedString) + assert extra == spec.extra + + +def test_connector_optional_metadata_returns_extended_values(monkeypatch) -> None: + """Connector optional dependency metadata helpers return extended values.""" + monkeypatch.setattr(_optional, "is_available", lambda package: package == "present") + monkeypatch.setitem(_optional.CONNECTOR_REQUIREMENTS, "custom", ["present", "missing"]) + monkeypatch.setitem(_optional.CONNECTOR_EXTRAS, "custom", "custom-extra") + + package_extra = _optional.get_extra_for_package("boto3") + connector_extra = _optional.get_extra_for_connector("custom") + requirements = _optional.get_connector_requirements("custom") + missing = _optional.get_missing_connector_requirements("custom") + install = _optional.get_connector_install_command("custom") + + assert isinstance(package_extra, ExtendedString) + assert package_extra == "aws" + assert isinstance(connector_extra, ExtendedString) + assert connector_extra == "custom-extra" + assert isinstance(requirements, ExtendedList) + assert requirements == ["present", "missing"] + assert isinstance(requirements[0], ExtendedString) + assert isinstance(missing, ExtendedList) + assert missing == ["missing"] + assert isinstance(missing[0], ExtendedString) + assert isinstance(install, ExtendedString) + assert install == "pip install extended-data[custom-extra]" + + +def test_builtin_connectors_are_registered_as_entry_points() -> None: + """Every built-in connector should be published through the connector entry point group.""" + entry_points = _pyproject()["project"]["entry-points"]["extended_data.connectors"] + + assert set(entry_points) == set(registry.BUILTIN_CONNECTORS) + + for name, spec in registry.BUILTIN_CONNECTORS.items(): + assert entry_points[name] == f"{spec.module_path}:{spec.class_name}" + + +def test_connector_extras_exist_in_pyproject() -> None: + """Connector extras referenced by registry metadata should exist in pyproject.""" + extras = _pyproject()["project"]["optional-dependencies"] + + for name, extra in _optional.CONNECTOR_EXTRAS.items(): + assert extra in extras, f"{name} uses missing extra {extra}" + + +def test_connector_requirement_packages_map_to_connector_extras() -> None: + """Connector import checks should point users to the same extra as the connector itself.""" + for name, requirements in _optional.CONNECTOR_REQUIREMENTS.items(): + extra = _optional.CONNECTOR_EXTRAS[name] + + for requirement in requirements: + assert _optional.PACKAGE_TO_EXTRA[requirement] == extra + + +def test_get_crewai_tool_decorator_explains_user_managed_install(monkeypatch) -> None: + """Missing CrewAI reports the deliberate no-extra install policy.""" + + def fake_import_module(name: str) -> object: + if name == "crewai.tools": + raise ImportError("No module named 'crewai'") + pytest.fail(f"unexpected import: {name}") + + monkeypatch.setattr(_optional.importlib, "import_module", fake_import_module) + + with pytest.raises(ImportError) as exc_info: + _optional.get_crewai_tool_decorator() + + message = str(exc_info.value) + assert "crewai is required for CrewAI tools" in message + assert "extended-data does not publish a CrewAI extra" in message + assert "chromadb" in message + assert "extended-data[crewai]" not in message + + +def test_sentence_transformers_explains_user_managed_install(monkeypatch) -> None: + """Missing sentence-transformers reports the deliberate no-extra install policy.""" + + def fake_import_module(name: str) -> object: + if name == "sentence_transformers": + raise ImportError("No module named 'sentence_transformers'") + pytest.fail(f"unexpected import: {name}") + + monkeypatch.setattr(_optional.importlib, "import_module", fake_import_module) + + with pytest.raises(ImportError) as exc_info: + _optional.require_extra("sentence_transformers") + + message = str(exc_info.value) + assert "sentence-transformers separately" in message + assert "torch" in message + assert "extended-data[vector]" not in message + assert _optional.get_extra_for_package("sentence_transformers") is None + + +def test_get_crewai_tool_decorator_returns_tool_decorator(monkeypatch) -> None: + """Installed CrewAI tool support is returned directly.""" + sentinel = object() + + monkeypatch.setattr(_optional.importlib, "import_module", lambda name: SimpleNamespace(tool=sentinel)) + + assert _optional.get_crewai_tool_decorator() is sentinel + + +def test_get_crewai_tool_decorator_rejects_incompatible_crewai(monkeypatch) -> None: + """A CrewAI install without crewai.tools.tool is treated as unsupported.""" + monkeypatch.setattr(_optional.importlib, "import_module", lambda name: SimpleNamespace()) + + with pytest.raises(ImportError, match="does not expose it"): + _optional.get_crewai_tool_decorator() + + +def test_framework_detection_returns_extended_metadata(monkeypatch) -> None: + """AI framework availability helpers return first-class extended values.""" + available = {"langchain_core": True, "crewai": False, "strands": True, "mcp": False} + monkeypatch.setattr(_optional, "is_available", lambda package: available[package]) + + detected = _optional.detect_ai_frameworks() + frameworks = _optional.get_available_ai_frameworks() + + assert isinstance(detected, ExtendedDict) + assert detected == {"langchain": True, "crewai": False, "strands": True, "mcp": False} + assert isinstance(frameworks, ExtendedList) + assert frameworks == ["langchain", "strands"] + assert isinstance(frameworks[0], ExtendedString) + + +def test_available_connectors_returns_extended_names(monkeypatch) -> None: + """Connector availability helper returns first-class extended names.""" + monkeypatch.setattr(_optional, "is_connector_available", lambda connector: connector in {"cursor", "meshy"}) + + connectors = _optional.get_available_connectors() + + assert isinstance(connectors, ExtendedList) + assert "cursor" in connectors + assert "meshy" in connectors + assert isinstance(connectors[0], ExtendedString) diff --git a/tests/connectors/test_secrets.py b/tests/connectors/test_secrets.py index b804c25..5e12d1c 100644 --- a/tests/connectors/test_secrets.py +++ b/tests/connectors/test_secrets.py @@ -1,30 +1,56 @@ import json +from inspect import getsource, signature +from pathlib import Path from unittest.mock import MagicMock, patch import pytest import yaml +from extended_data.connectors import secrets as secrets_module from extended_data.connectors.secrets import ( + ConfigInfo, OutputFormat, SecretsConnector, SyncOperation, SyncOptions, + SyncResult, ) +from extended_data.connectors.secrets.tools import ( + RunPipelineSchema, + dry_run, + get_config_info, + get_sources, + get_targets, + run_pipeline, + validate_config, +) +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString, extend_data +from extended_data.io import DataFile +from extended_data.primitives.formats.errors import DataDecodeError @pytest.fixture -def mock_logger(): +def mock_logger() -> MagicMock: return MagicMock() @pytest.fixture -def connector(mock_logger): - # Force CLI mode by setting prefer_native=False - return SecretsConnector(cli_path="/usr/bin/secretsync", prefer_native=False, logger=mock_logger) +def connector(mock_logger: MagicMock) -> SecretsConnector: + return SecretsConnector(cli_path="/usr/bin/secretsync", logger=mock_logger) + + +def test_secrets_connector_has_single_cli_runtime_contract() -> None: + """SecretSync integration should not expose unowned native binding switches.""" + parameters = signature(SecretsConnector).parameters + assert "prefer_native" not in parameters + assert not hasattr(SecretsConnector(cli_path="/usr/bin/secretsync"), "native_available") + assert "Native mode" not in getsource(SecretsConnector) + assert "native bindings" not in getsource(SecretsConnector) -def test_cli_get_config_info_valid(connector, tmp_path): + +def test_cli_get_config_info_valid(connector: SecretsConnector, tmp_path: Path) -> None: config_file = tmp_path / "config.yaml" config_data = { "sources": {"src1": {}, "src2": {}}, @@ -37,43 +63,91 @@ def test_cli_get_config_info_valid(connector, tmp_path): info = connector.get_config_info(str(config_file)) - assert info.valid is True - assert info.source_count == 2 - assert info.target_count == 1 - assert "src1" in info.sources - assert "src2" in info.sources - assert "tgt1" in info.targets - assert info.has_merge_store is True - assert info.vault_address == "http://vault:8200" - assert info.aws_region == "us-east-1" + assert isinstance(info, ExtendedDict) + assert isinstance(info["sources"], ExtendedList) + assert info["valid"] is True + assert info["source_count"] == 2 + assert info["target_count"] == 1 + assert "src1" in info["sources"] + assert "src2" in info["sources"] + assert "tgt1" in info["targets"] + assert info["has_merge_store"] is True + assert info["vault_address"] == "http://vault:8200" + assert info["aws_region"] == "us-east-1" + + +@patch("extended_data.connectors.secrets.DataFile.read") +def test_cli_get_config_info_reads_through_data_file( + mock_read: MagicMock, + connector: SecretsConnector, +) -> None: + mock_read.return_value = DataFile.decode( + "sources:\n src1: {}\ntargets:\n tgt1: {}\n", + file_path="config.yaml", + suffix="yaml", + ) + + info = connector.get_config_info("config.yaml") + + mock_read.assert_called_once_with("config.yaml", suffix="yaml", as_extended=True) + assert info["valid"] is True + assert info["source_count"] == 1 + assert info["target_count"] == 1 + assert info["sources"] == ["src1"] + assert info["targets"] == ["tgt1"] -def test_cli_get_config_info_not_found(connector): +def test_cli_get_config_info_not_found(connector: SecretsConnector) -> None: info = connector.get_config_info("/non/existent/path.yaml") - assert info.valid is False - assert "Configuration file not found" in info.error_message + assert isinstance(info, ExtendedDict) + assert info["valid"] is False + assert "Configuration file not found" in info["error_message"] -def test_cli_get_config_info_invalid_yaml(connector, tmp_path): +def test_cli_get_config_info_invalid_yaml(connector: SecretsConnector, tmp_path: Path) -> None: config_file = tmp_path / "config.yaml" config_file.write_text("invalid: yaml: :") info = connector.get_config_info(str(config_file)) - assert info.valid is False - assert "Error parsing YAML file" in info.error_message + assert isinstance(info, ExtendedDict) + assert info["valid"] is False + assert "Error parsing YAML file" in info["error_message"] -def test_cli_get_config_info_empty_file(connector, tmp_path): +def test_cli_get_config_info_empty_file(connector: SecretsConnector, tmp_path: Path) -> None: config_file = tmp_path / "config.yaml" config_file.write_text("") info = connector.get_config_info(str(config_file)) - assert info.valid is True - assert info.source_count == 0 + assert isinstance(info, ExtendedDict) + assert info["valid"] is True + assert info["source_count"] == 0 + + +def test_cli_get_targets_and_sources_return_extended_payloads(connector: SecretsConnector, tmp_path: Path) -> None: + config_file = tmp_path / "config.yaml" + config_file.write_text( + yaml.dump({ + "sources": {"vault/prod": {}, "vault/dev": {}}, + "targets": {"prod": {}, "dev": {}}, + }) + ) + + targets = connector.get_targets(str(config_file)) + sources = connector.get_sources(str(config_file)) + + assert isinstance(targets, ExtendedDict) + assert isinstance(targets["targets"], ExtendedList) + assert isinstance(targets["targets"][0], ExtendedString) + assert targets["count"] == 2 + assert set(targets["targets"]) == {"prod", "dev"} + assert isinstance(sources, ExtendedDict) + assert isinstance(sources["sources"], ExtendedList) + assert set(sources["sources"]) == {"vault/prod", "vault/dev"} @patch("subprocess.run") -def test_cli_run_pipeline_operation(mock_run, connector): +def test_cli_run_pipeline_operation(mock_run: MagicMock, connector: SecretsConnector) -> None: mock_run.return_value = MagicMock( returncode=0, stdout=json.dumps({"success": True, "secrets_processed": 5}), @@ -83,17 +157,20 @@ def test_cli_run_pipeline_operation(mock_run, connector): options = SyncOptions(operation=SyncOperation.MERGE) result = connector.run_pipeline("config.yaml", options) - assert result.success is True - assert result.secrets_processed == 5 + assert isinstance(result, ExtendedDict) + assert result["success"] is True + assert result["secrets_processed"] == 5 # Check that it uses "pipeline" command with "--merge-only" flag args = mock_run.call_args[0][0] assert args[1] == "pipeline" assert "--merge-only" in args + assert args.count("--output") == 1 + assert args[args.index("--output") + 1] == "json" @patch("subprocess.run") -def test_cli_run_pipeline_diff_and_format(mock_run, connector): +def test_cli_run_pipeline_diff_and_format(mock_run: MagicMock, connector: SecretsConnector) -> None: mock_run.return_value = MagicMock( returncode=0, stdout=json.dumps({"success": True, "diff_output": "some diff"}), @@ -106,25 +183,519 @@ def test_cli_run_pipeline_diff_and_format(mock_run, connector): ) result = connector.run_pipeline("config.yaml", options) - assert result.success is True + assert isinstance(result, ExtendedDict) + assert result["success"] is True args = mock_run.call_args[0][0] assert "--diff" in args - assert "--output" in args - assert "json" in args + assert args.count("--output") == 1 + assert args[args.index("--output") + 1] == "json" + + +@patch("subprocess.run") +def test_cli_run_pipeline_default_output_is_json(mock_run: MagicMock, connector: SecretsConnector) -> None: + mock_run.return_value = MagicMock( + returncode=0, + stdout=json.dumps({"success": True}), + stderr="", + ) + + result = connector.run_pipeline("config.yaml") + + assert isinstance(result, ExtendedDict) + assert result["success"] is True + args = mock_run.call_args[0][0] + assert args.count("--output") == 1 + assert args[args.index("--output") + 1] == "json" + assert "--parallelism" not in args + assert "--continue-on-error=true" in args + + +@patch("subprocess.run") +def test_cli_run_pipeline_parses_result_envelope(mock_run: MagicMock, connector: SecretsConnector) -> None: + output = { + "success": True, + "target_count": 2, + "secrets_processed": 5, + "secrets_added": 1, + "secrets_modified": 2, + "secrets_removed": 0, + "secrets_unchanged": 2, + "duration_ms": 321, + "results": [ + {"target": "prod", "phase": "merge", "success": True}, + {"target": "prod", "phase": "sync", "success": True}, + ], + "diff_output": '{"summary":{"added":1}}', + "diff": {"dry_run": True}, + } + mock_run.return_value = MagicMock( + returncode=0, + stdout=json.dumps(output), + stderr="", + ) + + result = connector.run_pipeline("config.yaml") + + assert isinstance(result, ExtendedDict) + assert result["success"] is True + assert result["target_count"] == 2 + assert result["secrets_processed"] == 5 + assert result["secrets_added"] == 1 + assert result["secrets_modified"] == 2 + assert result["secrets_unchanged"] == 2 + assert result["duration_ms"] == 321 + assert json.loads(str(result["results_json"])) == output["results"] + assert result["diff_output"] == '{"summary":{"added":1}}' + + +@patch("extended_data.connectors.secrets.decode_file", wraps=secrets_module.decode_file) +@patch("subprocess.run") +def test_cli_run_pipeline_decodes_result_envelope_through_data_boundary( + mock_run: MagicMock, + mock_decode_file: MagicMock, + connector: SecretsConnector, +) -> None: + """SecretSync JSON envelopes should use shared data decoding, not local json.loads.""" + stdout = json.dumps({"success": True, "results": [{"target": "prod"}]}) + mock_run.return_value = MagicMock( + returncode=0, + stdout=stdout, + stderr="", + ) + + result = connector.run_pipeline("config.yaml") + + assert result["success"] is True + assert '"target": "prod"' in result["results_json"] + mock_decode_file.assert_called_once_with(stdout, suffix="json", as_extended=True) + + +def test_sync_result_results_json_uses_shared_export_boundary() -> None: + """SecretSync result details should serialize through the shared export boundary.""" + output = {"success": True, "results": [{"target": "prod"}]} + + with patch( + "extended_data.connectors.secrets.wrap_raw_data_for_export", + wraps=secrets_module.wrap_raw_data_for_export, + ) as mock_wrap_for_export: + result = SyncResult.from_cli_output(output) + + assert '"target": "prod"' in result.results_json + mock_wrap_for_export.assert_called_once_with(output["results"], allow_encoding="json", indent_2=True) + + +@patch("subprocess.run") +def test_cli_run_pipeline_rejects_legacy_raw_diff_json(mock_run: MagicMock, connector: SecretsConnector) -> None: + mock_run.return_value = MagicMock( + returncode=0, + stdout=json.dumps( + { + "dry_run": True, + "summary": {"added": 1, "modified": 0, "removed": 0, "unchanged": 0}, + "targets": [], + } + ), + stderr="", + ) + + result = connector.run_pipeline("config.yaml", SyncOptions(dry_run=True, compute_diff=True)) + + assert isinstance(result, ExtendedDict) + assert result["success"] is False + assert "expected pipeline result envelope" in result["error_message"] + assert "native bindings" not in result["error_message"] + + +@patch("subprocess.run") +def test_cli_run_pipeline_parses_failure_result_envelope(mock_run: MagicMock, connector: SecretsConnector) -> None: + mock_run.return_value = MagicMock( + returncode=1, + stdout=json.dumps( + { + "success": False, + "target_count": 1, + "secrets_processed": 2, + "error_message": "pipeline completed with errors", + "results": [{"target": "prod", "phase": "sync", "success": False, "error": "denied"}], + } + ), + stderr="Error: pipeline completed with errors\n", + ) + + result = connector.run_pipeline("config.yaml") + + assert isinstance(result, ExtendedDict) + assert result["success"] is False + assert result["target_count"] == 1 + assert result["secrets_processed"] == 2 + assert result["error_message"] == "pipeline completed with errors" + assert json.loads(str(result["results_json"]))[0]["error"] == "denied" + + +@patch("subprocess.run") +def test_cli_run_pipeline_redacts_failure_result_envelope( + mock_run: MagicMock, + connector: SecretsConnector, +) -> None: + mock_run.return_value = MagicMock( + returncode=1, + stdout=json.dumps( + { + "success": False, + "error_message": "pipeline failed password=hunter2 Authorization: Bearer raw_token", + "results": [ + { + "target": "prod", + "success": False, + "error": "target denied api_key=key_123", + "password": "hunter2", + } + ], + "diff_output": "changed token=tok_123", + } + ), + stderr="", + ) + + result = connector.run_pipeline("config.yaml") + + assert result["success"] is False + assert "hunter2" not in result["error_message"] + assert "raw_token" not in result["error_message"] + assert "[REDACTED]" in result["error_message"] + assert "hunter2" not in result["results_json"] + assert "key_123" not in result["results_json"] + assert '"password": "[REDACTED]"' in result["results_json"] + assert "tok_123" not in result["diff_output"] + assert "[REDACTED]" in result["diff_output"] + + +@patch("subprocess.run") +def test_cli_run_pipeline_failure_envelope_uses_stderr_when_error_message_missing( + mock_run: MagicMock, + connector: SecretsConnector, +) -> None: + mock_run.return_value = MagicMock( + returncode=1, + stdout=json.dumps({"success": False, "results": []}), + stderr="Error: boom\n", + ) + + result = connector.run_pipeline("config.yaml") + + assert isinstance(result, ExtendedDict) + assert result["success"] is False + assert result["error_message"] == "Error: boom\n" + + +@patch("subprocess.run") +def test_cli_run_pipeline_success_without_json_is_error(mock_run: MagicMock, connector: SecretsConnector) -> None: + mock_run.return_value = MagicMock( + returncode=0, + stdout="", + stderr="", + ) + + result = connector.run_pipeline("config.yaml") + + assert isinstance(result, ExtendedDict) + assert result["success"] is False + assert "produced no JSON output" in result["error_message"] + + +@patch("extended_data.connectors.secrets.decode_file") +@patch("subprocess.run") +def test_cli_run_pipeline_success_parse_error_is_redacted( + mock_run: MagicMock, + mock_decode_file: MagicMock, + connector: SecretsConnector, +) -> None: + mock_run.return_value = MagicMock( + returncode=0, + stdout="not json", + stderr="", + ) + mock_decode_file.side_effect = DataDecodeError( + "JSON", + reason="invalid password=hunter2 Authorization: Bearer raw_token", + ) + + result = connector.run_pipeline("config.yaml") + + assert result["success"] is False + assert "hunter2" not in result["error_message"] + assert "raw_token" not in result["error_message"] + assert "[REDACTED]" in result["error_message"] + + +@patch("subprocess.run") +def test_cli_run_pipeline_non_json_failure_uses_cli_output(mock_run: MagicMock, connector: SecretsConnector) -> None: + mock_run.return_value = MagicMock( + returncode=1, + stdout="not json", + stderr="", + ) + + result = connector.run_pipeline("config.yaml") + + assert isinstance(result, ExtendedDict) + assert result["success"] is False + assert result["error_message"] == "not json" + + +@patch("subprocess.run") +def test_cli_run_pipeline_non_json_failure_redacts_cli_output( + mock_run: MagicMock, + connector: SecretsConnector, +) -> None: + mock_run.return_value = MagicMock( + returncode=1, + stdout="", + stderr="failed password=hunter2 Authorization: Bearer raw_token", + ) + + result = connector.run_pipeline("config.yaml") + + assert result["success"] is False + assert "hunter2" not in result["error_message"] + assert "raw_token" not in result["error_message"] + assert "[REDACTED]" in result["error_message"] + + +@patch("subprocess.run") +def test_cli_run_pipeline_only_emits_supported_cli_flags(mock_run: MagicMock, connector: SecretsConnector) -> None: + mock_run.return_value = MagicMock( + returncode=0, + stdout=json.dumps({"success": True}), + stderr="", + ) + + options = SyncOptions( + targets=["prod", "staging"], + continue_on_error=False, + parallelism=12, + ) + connector.run_pipeline("config.yaml", options) + + args = mock_run.call_args[0][0] + assert "--targets" in args + assert args[args.index("--targets") + 1] == "prod,staging" + assert "--parallelism" in args + assert args[args.index("--parallelism") + 1] == "12" + assert "--continue-on-error=false" in args @patch("subprocess.run") -def test_cli_validate_config(mock_run, connector): +def test_cli_validate_config(mock_run: MagicMock, connector: SecretsConnector) -> None: mock_run.return_value = MagicMock( returncode=0, stdout="Valid", stderr="", ) - is_valid, message = connector.validate_config("config.yaml") - assert is_valid is True - assert "valid" in message.lower() + validation = connector.validate_config("config.yaml") + assert isinstance(validation, ExtendedDict) + assert validation["valid"] is True + assert "valid" in validation["message"].lower() args = mock_run.call_args[0][0] assert "validate" in args + + +@patch("subprocess.run") +def test_cli_validate_config_redacts_cli_output(mock_run: MagicMock, connector: SecretsConnector) -> None: + mock_run.return_value = MagicMock( + returncode=1, + stdout="", + stderr="invalid password=hunter2 Authorization: Bearer raw_token", + ) + + validation = connector.validate_config("config.yaml") + + assert validation["valid"] is False + assert "hunter2" not in validation["message"] + assert "raw_token" not in validation["message"] + assert "[REDACTED]" in validation["message"] + + +@patch("extended_data.connectors.secrets.SecretsConnector") +def test_run_pipeline_tool_default_continue_on_error_matches_cli(mock_connector_class: MagicMock) -> None: + mock_connector = mock_connector_class.return_value + mock_connector.run_pipeline.return_value = SyncResult(success=True, secrets_processed=3).to_dict() + + result = run_pipeline("config.yaml") + + options = mock_connector.run_pipeline.call_args.args[1] + assert isinstance(options, SyncOptions) + assert options.continue_on_error is True + assert isinstance(result, ExtendedDict) + assert isinstance(result["secrets_processed"], int) + assert result["success"] is True + assert result["secrets_processed"] == 3 + + +@patch("extended_data.connectors.secrets.SecretsConnector") +def test_run_pipeline_tool_can_disable_continue_on_error(mock_connector_class: MagicMock) -> None: + mock_connector = mock_connector_class.return_value + mock_connector.run_pipeline.return_value = SyncResult(success=True).to_dict() + + run_pipeline("config.yaml", continue_on_error=False) + + options = mock_connector.run_pipeline.call_args.args[1] + assert isinstance(options, SyncOptions) + assert options.continue_on_error is False + + +def test_run_pipeline_schema_default_continue_on_error_matches_cli() -> None: + schema = RunPipelineSchema(config_path="config.yaml") + + assert schema.continue_on_error is True + + +@patch("extended_data.connectors.secrets.SecretsConnector") +def test_validate_config_tool_returns_extended_payload(mock_connector_class: MagicMock) -> None: + mock_connector = mock_connector_class.return_value + mock_connector.validate_config.return_value = extend_data({ + "valid": True, + "message": "valid config", + "config_path": "config.yaml", + }) + + result = validate_config("config.yaml") + + assert isinstance(result, ExtendedDict) + assert isinstance(result["message"], ExtendedString) + assert result["valid"] is True + assert result["config_path"] == "config.yaml" + + +@patch("extended_data.connectors.secrets.SecretsConnector") +def test_validate_config_tool_redacts_connector_payload(mock_connector_class: MagicMock) -> None: + mock_connector = mock_connector_class.return_value + mock_connector.validate_config.return_value = { + "valid": False, + "message": "invalid password=hunter2 Authorization: Bearer raw_token", + "config_path": "config.yaml", + } + + result = validate_config("config.yaml") + + assert result["valid"] is False + assert "hunter2" not in result["message"] + assert "raw_token" not in result["message"] + assert "[REDACTED]" in result["message"] + + +@patch("extended_data.connectors.secrets.SecretsConnector") +def test_dry_run_tool_returns_extended_payload(mock_connector_class: MagicMock) -> None: + mock_connector = mock_connector_class.return_value + mock_connector.dry_run.return_value = SyncResult( + success=True, + target_count=2, + secrets_added=1, + secrets_modified=2, + secrets_removed=0, + secrets_unchanged=3, + diff_output="diff", + ).to_dict() + + result = dry_run("config.yaml") + + assert isinstance(result, ExtendedDict) + assert isinstance(result["diff_output"], ExtendedString) + assert result["secrets_would_add"] == 1 + + +@patch("extended_data.connectors.secrets.SecretsConnector") +def test_run_pipeline_tool_redacts_connector_payload_summary(mock_connector_class: MagicMock) -> None: + mock_connector = mock_connector_class.return_value + mock_connector.run_pipeline.return_value = { + "success": False, + "error_message": "pipeline failed password=hunter2 Authorization: Bearer raw_token", + "diff_output": "changed token=tok_123", + } + + result = run_pipeline("config.yaml", dry_run=True) + + assert result["success"] is False + assert "hunter2" not in result["error_message"] + assert "raw_token" not in result["error_message"] + assert "tok_123" not in result["diff_output"] + assert "[REDACTED]" in result["error_message"] + assert "[REDACTED]" in result["diff_output"] + + +@patch("extended_data.connectors.secrets.SecretsConnector") +def test_dry_run_tool_redacts_connector_payload_summary(mock_connector_class: MagicMock) -> None: + mock_connector = mock_connector_class.return_value + mock_connector.dry_run.return_value = { + "success": False, + "error_message": "dry run failed password=hunter2 Authorization: Bearer raw_token", + "diff_output": "changed token=tok_123", + } + + result = dry_run("config.yaml") + + assert result["success"] is False + assert "hunter2" not in result["error_message"] + assert "raw_token" not in result["error_message"] + assert "tok_123" not in result["diff_output"] + assert "[REDACTED]" in result["error_message"] + assert "[REDACTED]" in result["diff_output"] + + +@patch("extended_data.connectors.secrets.SecretsConnector") +def test_get_config_info_tool_returns_extended_payload(mock_connector_class: MagicMock) -> None: + mock_connector = mock_connector_class.return_value + mock_connector.get_config_info.return_value = ConfigInfo( + valid=True, + source_count=1, + target_count=1, + sources=["vault/prod"], + targets=["aws/prod"], + has_merge_store=True, + vault_address="https://vault.example.com", + aws_region="us-east-1", + ).to_dict() + + result = get_config_info("config.yaml") + + assert isinstance(result, ExtendedDict) + assert isinstance(result["sources"], ExtendedList) + assert isinstance(result["sources"][0], ExtendedString) + assert result["targets"] == ["aws/prod"] + + +@patch("extended_data.connectors.secrets.SecretsConnector") +def test_get_targets_tool_returns_extended_payload(mock_connector_class: MagicMock) -> None: + mock_connector = mock_connector_class.return_value + mock_connector.get_targets.return_value = extend_data({ + "targets": ["prod", "dev"], + "count": 2, + "error_message": "", + }) + + result = get_targets("config.yaml") + + assert isinstance(result, ExtendedDict) + assert isinstance(result["targets"], ExtendedList) + assert isinstance(result["targets"][0], ExtendedString) + assert result["count"] == 2 + + +@patch("extended_data.connectors.secrets.SecretsConnector") +def test_get_sources_tool_returns_extended_payload(mock_connector_class: MagicMock) -> None: + mock_connector = mock_connector_class.return_value + mock_connector.get_sources.return_value = extend_data({ + "sources": ["vault/prod"], + "count": 1, + "error_message": "", + }) + + result = get_sources("config.yaml") + + assert isinstance(result, ExtendedDict) + assert isinstance(result["sources"], ExtendedList) + assert isinstance(result["sources"][0], ExtendedString) + assert result["count"] == 1 diff --git a/tests/connectors/test_slack_connector.py b/tests/connectors/test_slack_connector.py index c2a7026..5b38501 100644 --- a/tests/connectors/test_slack_connector.py +++ b/tests/connectors/test_slack_connector.py @@ -2,9 +2,123 @@ from __future__ import annotations +import importlib.util + from unittest.mock import MagicMock, patch -from extended_data.connectors.slack import SlackConnector +import pytest + +import extended_data.connectors.slack as slack_module + +from extended_data.connectors.slack import ( + SlackAPIError, + SlackConnector, + get_divider, + get_field_context_message_blocks, + get_header_block, + get_key_value_blocks, + get_rich_text_blocks, +) +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString + + +def test_slack_connector_requires_slack_sdk_when_constructed_without_extra(): + """Slack tool metadata imports without slack-sdk, but the connector still requires the extra.""" + if importlib.util.find_spec("slack_sdk") is not None: + pytest.skip("slack-sdk is installed") + + with pytest.raises(ImportError, match=r"extended-data\[slack\]"): + SlackConnector(token="xoxp-test", bot_token="xoxb-test", from_environment=False) + + +def test_slack_block_helpers_return_extended_payloads(): + """Slack block helper payloads are first-class extended containers.""" + divider = get_divider() + header = get_header_block("Deploys") + context = get_field_context_message_blocks("deploy", {"service": "api"}) + key_value = get_key_value_blocks("service", {"name": "api"}) + rich = get_rich_text_blocks(["hello"], bold=True) + + assert isinstance(divider, ExtendedDict) + assert isinstance(divider["type"], ExtendedString) + assert isinstance(header, ExtendedList) + assert isinstance(header[0], ExtendedDict) + assert isinstance(header[0]["text"], ExtendedDict) + assert isinstance(context, ExtendedList) + assert isinstance(context[0], ExtendedDict) + assert isinstance(key_value, ExtendedList) + assert isinstance(key_value[0]["text"], ExtendedDict) + assert isinstance(rich, ExtendedList) + assert isinstance(rich[0]["elements"], ExtendedList) + + +def test_slack_module_does_not_export_internal_batching_helper() -> None: + """Compatibility helpers should not become public connector surface.""" + assert not hasattr(slack_module, "batched") + + +def test_slack_api_error_redacts_sensitive_response_text() -> None: + """Slack API errors should not expose raw secret-bearing response values.""" + error = SlackAPIError({"ok": False, "password": "hunter2", "authorization": "Bearer raw_token"}) + + message = str(error) + assert "hunter2" not in message + assert "raw_token" not in message + assert "[REDACTED]" in message + assert error.response["password"] == "[REDACTED]" + assert error.response["authorization"] == "[REDACTED]" + + +def test_slack_response_payload_normalizes_sdk_shapes() -> None: + """Slack response normalization should redact mapping, SDK-data, get/status, and fallback shapes.""" + + class DataResponse: + data = {"ok": False, "token": "raw-token"} + status_code = 403 + + class GetterResponse: + status_code = 429 + + def get(self, key): + return {"ok": False, "error": "ratelimited", "warning": None}.get(key) + + mapping = slack_module._slack_response_payload({"ok": False, "password": "hunter2"}) + data = slack_module._slack_response_payload(DataResponse()) + getter = slack_module._slack_response_payload(GetterResponse()) + fallback = slack_module._slack_response_payload("authorization: Bearer raw-token") + + assert mapping["password"] == "[REDACTED]" + assert data["token"] == "[REDACTED]" + assert getter == {"ok": False, "error": "ratelimited", "status_code": 429} + assert "raw-token" not in fallback["response"] + assert "[REDACTED]" in fallback["response"] + + +def test_slack_block_helpers_skip_empty_values_and_apply_styles() -> None: + """Slack block helpers should encode mappings, skip empty context values, and apply styles.""" + context = get_field_context_message_blocks( + "deploy", + { + "service": "api", + "empty": "", + "details": {"region": "us-east-1"}, + **{f"k{i}": i for i in range(11)}, + }, + ) + key_value = get_key_value_blocks("count", 3) + rich = get_rich_text_blocks(["hello"], italic=True, strike=True) + + context_text = "\n".join( + str(element["text"]) + for block in context + if block["type"] == "context" + for element in block["elements"] + ) + assert "empty:" not in context_text + assert "details:" in context_text + assert len([block for block in context if block["type"] == "context"]) == 2 + assert key_value[0]["text"]["text"] == "*Count*: 3" + assert rich[0]["elements"][0]["style"] == {"italic": True, "strike": True} class TestSlackConnector: @@ -35,6 +149,9 @@ def test_get_bot_channels(self, mock_webclient_class, base_connector_kwargs): connector = SlackConnector(token="test-token", bot_token="bot-token", **base_connector_kwargs) channels = connector.get_bot_channels() + assert isinstance(channels, ExtendedDict) + assert isinstance(channels["general"], ExtendedDict) + assert isinstance(channels["general"]["id"], ExtendedString) assert "general" in channels assert channels["general"]["id"] == "C12345" @@ -52,9 +169,298 @@ def test_send_message(self, mock_webclient_class, base_connector_kwargs): ts = connector.send_message(channel_name="general", text="Test message", blocks=[]) + assert isinstance(ts, ExtendedString) assert ts == "1234567890.123456" mock_bot_client.chat_postMessage.assert_called_once() + @patch("extended_data.connectors.slack.WebClient") + def test_send_message_includes_thread_id(self, mock_webclient_class, base_connector_kwargs): + """Thread replies should pass Slack's thread_ts option through to the SDK.""" + mock_bot_client = MagicMock() + mock_bot_client.users_conversations.return_value = {"channels": [{"name": "general", "id": "C12345"}]} + mock_bot_client.chat_postMessage.return_value = {"ts": "1234567890.123456"} + mock_user_client = MagicMock() + mock_webclient_class.side_effect = [mock_user_client, mock_bot_client] + connector = SlackConnector(token="test-token", bot_token="bot-token", **base_connector_kwargs) + + connector.send_message(channel_name="general", text="Reply", blocks=[], thread_id="1234567890.000001") + + assert mock_bot_client.chat_postMessage.call_args.kwargs["thread_ts"] == "1234567890.000001" + + @patch("extended_data.connectors.slack.WebClient") + def test_send_message_converts_extended_blocks_for_sdk(self, mock_webclient_class, base_connector_kwargs): + """Slack SDK calls should receive builtin payloads even when helpers are extended.""" + mock_bot_client = MagicMock() + mock_bot_client.users_conversations.return_value = {"channels": [{"name": "general", "id": "C12345"}]} + mock_bot_client.chat_postMessage.return_value = {"ts": "1234567890.123456"} + + mock_user_client = MagicMock() + mock_webclient_class.side_effect = [mock_user_client, mock_bot_client] + + connector = SlackConnector(token="test-token", bot_token="bot-token", **base_connector_kwargs) + + connector.send_message(channel_name="general", text="Test message", lines=["hello"], bold=True) + + kwargs = mock_bot_client.chat_postMessage.call_args.kwargs + assert isinstance(kwargs["blocks"], list) + assert not isinstance(kwargs["blocks"], ExtendedList) + assert isinstance(kwargs["blocks"][0], dict) + assert not isinstance(kwargs["blocks"][0], ExtendedDict) + assert isinstance(kwargs["channel"], str) + + @patch("extended_data.connectors.slack.WebClient") + def test_send_message_non_raising_api_error_returns_extended_payload( + self, + mock_webclient_class, + base_connector_kwargs, + ): + """Non-raising Slack send failures should not leak raw SDK response objects.""" + + class FakeSlackApiError(Exception): + def __init__(self, response): + self.response = response + + mock_bot_client = MagicMock() + mock_bot_client.users_conversations.return_value = {"channels": [{"name": "general", "id": "C12345"}]} + mock_bot_client.chat_postMessage.side_effect = FakeSlackApiError( + {"ok": False, "error": "channel_not_found", "password": "hunter2"} + ) + + mock_user_client = MagicMock() + mock_webclient_class.side_effect = [mock_user_client, mock_bot_client] + + connector = SlackConnector(token="test-token", bot_token="bot-token", **base_connector_kwargs) + + with patch("extended_data.connectors.slack.SlackApiError", FakeSlackApiError): + result = connector.send_message( + channel_name="general", + text="Test message", + blocks=[], + raise_on_api_error=False, + ) + + assert isinstance(result, ExtendedDict) + assert isinstance(result["error"], ExtendedString) + assert result["error"] == "channel_not_found" + assert result["password"] == "[REDACTED]" + + @patch("extended_data.connectors.slack.WebClient") + def test_send_message_api_error_redacts_response_without_raw_cause( + self, + mock_webclient_class, + base_connector_kwargs, + ): + """Raising Slack send failures should not preserve raw SDK exceptions.""" + + class FakeSlackApiError(Exception): + def __init__(self, response): + self.response = response + + mock_bot_client = MagicMock() + mock_bot_client.users_conversations.return_value = {"channels": [{"name": "general", "id": "C12345"}]} + mock_bot_client.chat_postMessage.side_effect = FakeSlackApiError( + {"ok": False, "error": "channel_not_found", "password": "hunter2", "token": "raw-token"} + ) + + mock_user_client = MagicMock() + mock_webclient_class.side_effect = [mock_user_client, mock_bot_client] + + connector = SlackConnector(token="test-token", bot_token="bot-token", **base_connector_kwargs) + + with ( + patch("extended_data.connectors.slack.SlackApiError", FakeSlackApiError), + pytest.raises(SlackAPIError) as exc_info, + ): + connector.send_message(channel_name="general", text="Test message", blocks=[]) + + diagnostics = str(exc_info.value) + str(exc_info.value.response) + assert "hunter2" not in diagnostics + assert "raw-token" not in diagnostics + assert "[REDACTED]" in diagnostics + assert exc_info.value.__cause__ is None + + @patch("extended_data.connectors.slack.WebClient") + def test_send_message_redacts_missing_channel_name(self, mock_webclient_class, base_connector_kwargs): + """Missing-channel errors should not echo caller-provided channel names.""" + mock_bot_client = MagicMock() + mock_bot_client.users_conversations.return_value = {"channels": []} + + mock_user_client = MagicMock() + mock_webclient_class.side_effect = [mock_user_client, mock_bot_client] + + connector = SlackConnector(token="test-token", bot_token="bot-token", **base_connector_kwargs) + + with pytest.raises(RuntimeError) as exc_info: + connector.send_message(channel_name="private-channel", text="Test message", blocks=[]) + + assert "private-channel" not in str(exc_info.value) + assert "[REDACTED]" in str(exc_info.value) + + @patch("extended_data.connectors.slack.WebClient") + def test_send_message_redacts_missing_channel_id(self, mock_webclient_class, base_connector_kwargs): + """Channels without IDs should fail without echoing caller-provided channel names.""" + mock_bot_client = MagicMock() + mock_bot_client.users_conversations.return_value = {"channels": [{"name": "private-channel", "id": ""}]} + + mock_user_client = MagicMock() + mock_webclient_class.side_effect = [mock_user_client, mock_bot_client] + + connector = SlackConnector(token="test-token", bot_token="bot-token", **base_connector_kwargs) + + with pytest.raises(RuntimeError) as exc_info: + connector.send_message(channel_name="private-channel", text="Test message", blocks=[]) + + assert "private-channel" not in str(exc_info.value) + assert "[REDACTED]" in str(exc_info.value) + + @patch("extended_data.connectors.slack.WebClient") + def test_get_bot_channels_api_error_redacts_response_without_raw_cause( + self, + mock_webclient_class, + base_connector_kwargs, + ): + """Bot-channel lookup failures should wrap redacted Slack responses.""" + + class FakeSlackApiError(Exception): + def __init__(self, response): + self.response = response + + mock_bot_client = MagicMock() + mock_bot_client.users_conversations.side_effect = FakeSlackApiError( + {"ok": False, "error": "token_revoked", "authorization": "Bearer raw_token"} + ) + + mock_user_client = MagicMock() + mock_webclient_class.side_effect = [mock_user_client, mock_bot_client] + + connector = SlackConnector(token="test-token", bot_token="bot-token", **base_connector_kwargs) + + with ( + patch("extended_data.connectors.slack.SlackApiError", FakeSlackApiError), + pytest.raises(SlackAPIError) as exc_info, + ): + connector.get_bot_channels() + + diagnostics = str(exc_info.value) + str(exc_info.value.response) + assert "raw_token" not in diagnostics + assert "[REDACTED]" in diagnostics + assert exc_info.value.__cause__ is None + + @patch("extended_data.connectors.slack.WebClient") + def test_call_api_redacts_grouping_failure_payload(self, mock_webclient_class, base_connector_kwargs): + """Slack grouping failures should not dump raw secret-bearing response data.""" + mock_user_client = MagicMock() + mock_user_client.users_list.return_value = { + "members": [{"name": "missing-id", "password": "hunter2", "authorization": "Bearer raw_token"}] + } + mock_bot_client = MagicMock() + mock_webclient_class.side_effect = [mock_user_client, mock_bot_client] + + connector = SlackConnector(token="test-token", bot_token="bot-token", **base_connector_kwargs) + + with pytest.raises(RuntimeError) as exc_info: + connector._call_api("users_list", group_by="members") + + message = str(exc_info.value) + assert "hunter2" not in message + assert "raw_token" not in message + assert "[REDACTED]" in message + + @patch("extended_data.connectors.slack.WebClient") + def test_call_api_non_rate_error_redacts_response_without_raw_cause( + self, + mock_webclient_class, + base_connector_kwargs, + ): + """Slack API failures should not preserve raw SDK exception causes.""" + + class FakeSlackApiError(Exception): + def __init__(self, response): + self.response = response + + mock_response = {"ok": False, "error": "bad_auth", "authorization": "Bearer raw_token"} + mock_user_client = MagicMock() + mock_user_client.users_list.side_effect = FakeSlackApiError(mock_response) + mock_bot_client = MagicMock() + mock_webclient_class.side_effect = [mock_user_client, mock_bot_client] + + connector = SlackConnector(token="test-token", bot_token="bot-token", **base_connector_kwargs) + + with ( + patch("extended_data.connectors.slack.SlackApiError", FakeSlackApiError), + pytest.raises(SlackAPIError) as exc_info, + ): + connector._call_api("users_list") + + diagnostics = str(exc_info.value) + str(exc_info.value.response) + assert "raw_token" not in diagnostics + assert "[REDACTED]" in diagnostics + assert exc_info.value.__cause__ is None + + @patch("extended_data.connectors.slack.WebClient") + def test_call_api_retries_rate_limits_and_groups_success(self, mock_webclient_class, base_connector_kwargs): + """Rate-limited Slack calls should sleep, retry, and group the successful response.""" + + class FakeSlackApiError(Exception): + def __init__(self, response): + self.response = response + + class FakeSlackResponse(dict): + headers = {"Retry-After": "2"} + + mock_user_client = MagicMock() + mock_user_client.users_list.side_effect = [ + FakeSlackApiError(FakeSlackResponse(error="ratelimited")), + {"members": [{"id": "U1", "name": "alice"}]}, + ] + mock_bot_client = MagicMock() + mock_webclient_class.side_effect = [mock_user_client, mock_bot_client] + connector = SlackConnector(token="test-token", bot_token="bot-token", **base_connector_kwargs) + + with ( + patch("extended_data.connectors.slack.SlackApiError", FakeSlackApiError), + patch("extended_data.connectors.slack.sleep") as sleep, + ): + result = connector._call_api("users_list", group_by="members") + + assert result == {"U1": {"id": "U1", "name": "alice"}} + sleep.assert_called_once_with(2) + assert mock_user_client.users_list.call_count == 2 + + @patch("extended_data.connectors.slack.WebClient") + def test_call_api_rate_limit_timeout(self, mock_webclient_class, base_connector_kwargs): + """Repeated rate limits should raise TimeoutError once the retry budget is exceeded.""" + + class FakeSlackApiError(Exception): + def __init__(self, response): + self.response = response + + class FakeSlackResponse(dict): + headers = {"Retry-After": "31"} + + mock_user_client = MagicMock() + mock_user_client.users_list.side_effect = FakeSlackApiError(FakeSlackResponse(error="ratelimited")) + mock_bot_client = MagicMock() + mock_webclient_class.side_effect = [mock_user_client, mock_bot_client] + connector = SlackConnector(token="test-token", bot_token="bot-token", **base_connector_kwargs) + + with ( + patch("extended_data.connectors.slack.SlackApiError", FakeSlackApiError), + pytest.raises(TimeoutError, match="timed out after 31 seconds"), + ): + connector._call_api("users_list") + + @patch("extended_data.connectors.slack.WebClient") + def test_call_api_rejects_unsupported_methods(self, mock_webclient_class, base_connector_kwargs): + """Unsupported WebClient methods should fail explicitly.""" + mock_user_client = MagicMock(spec=[]) + mock_bot_client = MagicMock() + mock_webclient_class.side_effect = [mock_user_client, mock_bot_client] + connector = SlackConnector(token="test-token", bot_token="bot-token", **base_connector_kwargs) + + with pytest.raises(AttributeError, match="not supported"): + connector._call_api("users_list") + @patch("extended_data.connectors.slack.SlackConnector._call_api") @patch("extended_data.connectors.slack.WebClient") def test_list_users_filters_deleted( @@ -85,6 +491,8 @@ def test_list_users_filters_deleted( include_app_users=False, ) + assert isinstance(users, ExtendedDict) + assert isinstance(users["U1"], ExtendedDict) assert list(users.keys()) == ["U1"] mock_call_api.assert_called_once_with( "users_list", @@ -94,6 +502,36 @@ def test_list_users_filters_deleted( team_id="T123", ) + @patch("extended_data.connectors.slack.SlackConnector._call_api") + @patch("extended_data.connectors.slack.WebClient") + def test_list_users_can_include_all_special_accounts( + self, + mock_webclient_class, + mock_call_api, + base_connector_kwargs, + ): + """Explicit inclusion flags should return deleted, bot, and app users unchanged.""" + mock_call_api.return_value = { + "U1": {"id": "U1", "deleted": True}, + "U2": {"id": "U2", "is_workflow_bot": True}, + "U3": {"id": "U3", "is_app_user": True}, + } + mock_user_client = MagicMock() + mock_bot_client = MagicMock() + mock_webclient_class.side_effect = [mock_user_client, mock_bot_client] + connector = SlackConnector(token="test-token", bot_token="bot-token", **base_connector_kwargs) + + users = connector.list_users( + include_locale=False, + limit=100, + team_id="T123", + include_deleted=True, + include_bots=True, + include_app_users=True, + ) + + assert users == mock_call_api.return_value + @patch("extended_data.connectors.slack.SlackConnector._call_api") @patch("extended_data.connectors.slack.WebClient") def test_list_usergroups_filters_ids( @@ -122,6 +560,8 @@ def test_list_usergroups_filters_ids( usergroup_ids="S1,S3", ) + assert isinstance(groups, ExtendedDict) + assert isinstance(groups["S1"]["name"], ExtendedString) assert groups == {"S1": {"id": "S1", "name": "Ops"}} mock_call_api.assert_called_once_with( "usergroups_list", @@ -132,6 +572,30 @@ def test_list_usergroups_filters_ids( team_id="T123", ) + @patch("extended_data.connectors.slack.SlackConnector._call_api") + @patch("extended_data.connectors.slack.WebClient") + def test_list_usergroups_returns_all_without_identifier_filter( + self, + mock_webclient_class, + mock_call_api, + base_connector_kwargs, + ): + """Usergroup listing should return all groups when no ID filter is supplied.""" + mock_call_api.return_value = { + "S1": {"id": "S1", "name": "Ops"}, + "S2": {"id": "S2", "name": "Eng"}, + } + mock_user_client = MagicMock() + mock_bot_client = MagicMock() + mock_webclient_class.side_effect = [mock_user_client, mock_bot_client] + connector = SlackConnector(token="test-token", bot_token="bot-token", **base_connector_kwargs) + + groups = connector.list_usergroups(usergroup_ids=[" ", ""]) + + assert groups == mock_call_api.return_value + assert SlackConnector._normalize_identifier_filter(["S1", " S2 ", "", "S1"]) == {"S1", "S2"} + assert SlackConnector._normalize_identifier_filter("") is None + @patch("extended_data.connectors.slack.SlackConnector._call_api") @patch("extended_data.connectors.slack.WebClient") def test_list_conversations_channels_only( @@ -161,6 +625,8 @@ def test_list_conversations_channels_only( cursor="cursor123", ) + assert isinstance(conversations, ExtendedDict) + assert isinstance(conversations["C1"], ExtendedDict) assert conversations == {"C1": {"id": "C1", "is_channel": True}} mock_call_api.assert_called_once_with( "conversations_list", @@ -171,3 +637,33 @@ def test_list_conversations_channels_only( types="private_channel,public_channel", cursor="cursor123", ) + + @patch("extended_data.connectors.slack.SlackConnector._call_api") + @patch("extended_data.connectors.slack.WebClient") + def test_list_conversations_returns_all_when_not_channels_only( + self, + mock_webclient_class, + mock_call_api, + base_connector_kwargs, + ): + """Conversation listing should preserve non-channel conversations unless filtered.""" + mock_call_api.return_value = { + "C1": {"id": "C1", "is_channel": True}, + "D1": {"id": "D1", "is_channel": False}, + } + mock_user_client = MagicMock() + mock_bot_client = MagicMock() + mock_webclient_class.side_effect = [mock_user_client, mock_bot_client] + connector = SlackConnector(token="test-token", bot_token="bot-token", **base_connector_kwargs) + + conversations = connector.list_conversations( + exclude_archived=False, + limit=100, + team_id="T123", + types="im", + get_members=False, + channels_only=False, + ) + + assert conversations == mock_call_api.return_value + assert mock_call_api.call_args.kwargs["types"] == "im" diff --git a/tests/connectors/test_slack_tools.py b/tests/connectors/test_slack_tools.py index a785411..5aef2f8 100644 --- a/tests/connectors/test_slack_tools.py +++ b/tests/connectors/test_slack_tools.py @@ -12,6 +12,8 @@ import pytest +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString + @pytest.fixture(autouse=True) def mock_slack_sdk(): @@ -90,9 +92,12 @@ def test_list_channels_basic(self, mock_slack_sdk): with patch("extended_data.connectors.slack.tools._get_connector", return_value=mock_connector): result = list_channels() + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) assert len(result) == 2 assert result[0]["id"] == "C12345" assert result[0]["name"] == "general" + assert isinstance(result[0]["name"], ExtendedString) assert result[0]["member_count"] == 42 def test_list_channels_with_archived(self, mock_slack_sdk): @@ -138,9 +143,12 @@ def test_list_users_basic(self, mock_slack_sdk): with patch("extended_data.connectors.slack.tools._get_connector", return_value=mock_connector): result = list_users() + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) assert len(result) == 2 assert result[0]["id"] == "U12345" assert result[0]["name"] == "john.doe" + assert isinstance(result[0]["email"], ExtendedString) assert result[0]["email"] == "john@example.com" assert result[0]["is_admin"] is True @@ -172,6 +180,8 @@ def test_send_message_basic(self, mock_slack_sdk): with patch("extended_data.connectors.slack.tools._get_connector", return_value=mock_connector): result = send_message(channel="general", text="Hello, world!") + assert isinstance(result, ExtendedDict) + assert isinstance(result["channel"], ExtendedString) assert result["channel"] == "general" assert result["text"] == "Hello, world!" assert result["timestamp"] == "1234567890.123456" @@ -223,9 +233,12 @@ def test_get_channel_history_basic(self, mock_slack_sdk): with patch("extended_data.connectors.slack.tools._get_connector", return_value=mock_connector): result = get_channel_history(channel="general") + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) assert len(result) == 2 assert result[0]["timestamp"] == "1234567890.123456" assert result[0]["user"] == "U12345" + assert isinstance(result[0]["text"], ExtendedString) assert result[0]["text"] == "Hello, world!" def test_get_channel_history_channel_not_found(self, mock_slack_sdk): @@ -238,6 +251,7 @@ def test_get_channel_history_channel_not_found(self, mock_slack_sdk): with patch("extended_data.connectors.slack.tools._get_connector", return_value=mock_connector): result = get_channel_history(channel="nonexistent") + assert isinstance(result, ExtendedList) assert len(result) == 0 diff --git a/tests/connectors/test_tool_framework_adapters.py b/tests/connectors/test_tool_framework_adapters.py new file mode 100644 index 0000000..a8b1a39 --- /dev/null +++ b/tests/connectors/test_tool_framework_adapters.py @@ -0,0 +1,154 @@ +"""Shared framework adapter contracts for connector tool modules.""" + +from __future__ import annotations + +from importlib import import_module +from typing import Any +from unittest.mock import MagicMock + +import pytest + + +TOOL_MODULES = ( + "extended_data.connectors.anthropic.tools", + "extended_data.connectors.aws.tools", + "extended_data.connectors.cursor.tools", + "extended_data.connectors.github.tools", + "extended_data.connectors.google.tools", + "extended_data.connectors.meshy.tools", + "extended_data.connectors.secrets.tools", + "extended_data.connectors.slack.tools", + "extended_data.connectors.vault.tools", + "extended_data.connectors.zoom.tools", +) + + +def _fake_crewai_tool(name: str): + def decorate(func: Any) -> MagicMock: + wrapped = MagicMock(wrapped_name=name) + wrapped.__name__ = func.__name__ + return wrapped + + return decorate + + +@pytest.mark.parametrize("module_name", TOOL_MODULES) +def test_langchain_tools_delegate_to_shared_builder(module_name: str, monkeypatch: pytest.MonkeyPatch) -> None: + """LangChain factories should pass connector definitions through the shared builder.""" + from extended_data.connectors import ai_tools + + module = import_module(module_name) + expected = [object()] + build_langchain_tools = MagicMock(return_value=expected) + monkeypatch.setattr(ai_tools, "build_langchain_tools", build_langchain_tools) + + assert module.get_langchain_tools() is expected + build_langchain_tools.assert_called_once_with(module.TOOL_DEFINITIONS) + + +@pytest.mark.parametrize("module_name", TOOL_MODULES) +def test_crewai_tools_attach_description_and_schema(module_name: str, monkeypatch: pytest.MonkeyPatch) -> None: + """CrewAI factories should attach connector metadata to wrapped functions.""" + from extended_data.connectors import _optional + + module = import_module(module_name) + monkeypatch.setattr(_optional, "get_crewai_tool_decorator", lambda: _fake_crewai_tool) + + tools = module.get_crewai_tools() + first_definition = module.TOOL_DEFINITIONS[0] + expected_schema = first_definition.get("schema") or first_definition.get("args_schema") + + assert len(tools) == len(module.TOOL_DEFINITIONS) + assert tools[0].description == first_definition["description"] + assert tools[0].args_schema is expected_schema + + +@pytest.mark.parametrize("module_name", TOOL_MODULES) +def test_crewai_tools_allow_schema_less_definitions(module_name: str, monkeypatch: pytest.MonkeyPatch) -> None: + """CrewAI factories should tolerate simple function definitions without schemas.""" + from extended_data.connectors import _optional + + class WrappedTool: + pass + + def fake_tool(name: str): + def decorate(func: Any) -> WrappedTool: + wrapped = WrappedTool() + wrapped.name = name + wrapped.func = func + return wrapped + + return decorate + + module = import_module(module_name) + monkeypatch.setattr(_optional, "get_crewai_tool_decorator", lambda: fake_tool) + monkeypatch.setattr( + module, + "TOOL_DEFINITIONS", + [{"name": "connector_ping", "description": "Ping connector", "func": lambda: "pong"}], + ) + + tools = module.get_crewai_tools() + + assert len(tools) == 1 + assert tools[0].description == "Ping connector" + assert not hasattr(tools[0], "args_schema") + + +@pytest.mark.parametrize("module_name", TOOL_MODULES) +def test_strands_tools_return_plain_definition_functions(module_name: str) -> None: + """Strands factories should expose the raw Python functions in definition order.""" + module = import_module(module_name) + + assert module.get_strands_tools() == [definition["func"] for definition in module.TOOL_DEFINITIONS] + + +@pytest.mark.parametrize("module_name", TOOL_MODULES) +def test_get_tools_auto_prefers_crewai(module_name: str, monkeypatch: pytest.MonkeyPatch) -> None: + """Auto-detection should prefer CrewAI when it is importable.""" + from extended_data.connectors import _optional + + module = import_module(module_name) + expected = [object()] + monkeypatch.setattr(_optional, "is_available", lambda package: package == "crewai") + monkeypatch.setattr(module, "get_crewai_tools", lambda: expected) + + assert module.get_tools("auto") is expected + + +@pytest.mark.parametrize("module_name", TOOL_MODULES) +def test_get_tools_auto_falls_back_to_langchain_then_strands( + module_name: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Auto-detection should use LangChain before plain Strands functions.""" + from extended_data.connectors import _optional + + module = import_module(module_name) + langchain_tools = [object()] + strands_tools = [object()] + availability = {"langchain_core": True} + monkeypatch.setattr(_optional, "is_available", lambda package: availability.get(package, False)) + monkeypatch.setattr(module, "get_langchain_tools", lambda: langchain_tools) + monkeypatch.setattr(module, "get_strands_tools", lambda: strands_tools) + + assert module.get_tools("auto") is langchain_tools + + availability["langchain_core"] = False + assert module.get_tools("auto") is strands_tools + + +@pytest.mark.parametrize("module_name", TOOL_MODULES) +def test_get_tools_explicit_framework_dispatch(module_name: str, monkeypatch: pytest.MonkeyPatch) -> None: + """Explicit framework names should dispatch to their matching factories.""" + module = import_module(module_name) + langchain_tools = [object()] + crewai_tools = [object()] + strands_tools = [object()] + monkeypatch.setattr(module, "get_langchain_tools", lambda: langchain_tools) + monkeypatch.setattr(module, "get_crewai_tools", lambda: crewai_tools) + monkeypatch.setattr(module, "get_strands_tools", lambda: strands_tools) + + assert module.get_tools("langchain") is langchain_tools + assert module.get_tools("crewai") is crewai_tools + assert module.get_tools("strands") is strands_tools diff --git a/tests/connectors/test_tool_frameworks.py b/tests/connectors/test_tool_frameworks.py new file mode 100644 index 0000000..1c8b036 --- /dev/null +++ b/tests/connectors/test_tool_frameworks.py @@ -0,0 +1,45 @@ +"""Shared framework selection tests for connector tool modules.""" + +from __future__ import annotations + +import importlib + +import pytest + + +TOOL_MODULES = ( + "extended_data.connectors.anthropic.tools", + "extended_data.connectors.aws.tools", + "extended_data.connectors.cursor.tools", + "extended_data.connectors.github.tools", + "extended_data.connectors.google.tools", + "extended_data.connectors.meshy.tools", + "extended_data.connectors.secrets.tools", + "extended_data.connectors.slack.tools", + "extended_data.connectors.vault.tools", + "extended_data.connectors.zoom.tools", +) + + +@pytest.mark.parametrize("module_path", TOOL_MODULES) +def test_get_tools_rejects_functions_alias(module_path: str) -> None: + """Plain-function tools should use the canonical strands framework name.""" + module = importlib.import_module(module_path) + + with pytest.raises(ValueError, match="Unknown framework"): + module.get_tools("functions") + + +@pytest.mark.parametrize("module_path", TOOL_MODULES) +def test_get_tools_redacts_unknown_framework_diagnostics(module_path: str) -> None: + """Unknown framework diagnostics should not echo secret-bearing input.""" + module = importlib.import_module(module_path) + + with pytest.raises(ValueError) as exc_info: + module.get_tools("password=hunter2 Authorization: Bearer raw_token") + + message = str(exc_info.value) + assert "hunter2" not in message + assert "raw_token" not in message + assert "[REDACTED]" in message + assert "auto, langchain, crewai, strands" in message diff --git a/tests/connectors/test_tool_payload_contracts.py b/tests/connectors/test_tool_payload_contracts.py new file mode 100644 index 0000000..fee6e7f --- /dev/null +++ b/tests/connectors/test_tool_payload_contracts.py @@ -0,0 +1,41 @@ +"""Contracts for connector AI-tool payload surfaces.""" + +from __future__ import annotations + +from importlib import import_module +from typing import get_args, get_origin, get_type_hints + +import pytest + +from extended_data.containers import ExtendedDict, ExtendedList + + +TOOL_MODULES = ( + "extended_data.connectors.anthropic.tools", + "extended_data.connectors.aws.tools", + "extended_data.connectors.cursor.tools", + "extended_data.connectors.github.tools", + "extended_data.connectors.google.tools", + "extended_data.connectors.meshy.tools", + "extended_data.connectors.secrets.tools", + "extended_data.connectors.slack.tools", + "extended_data.connectors.vault.tools", + "extended_data.connectors.zoom.tools", +) + + +@pytest.mark.parametrize("module_name", TOOL_MODULES) +def test_tool_definition_functions_advertise_extended_payloads(module_name: str) -> None: + """Data-returning AI tools expose Tier 2 payload contracts.""" + module = import_module(module_name) + + for definition in module.TOOL_DEFINITIONS: + func = definition["func"] + return_type = get_type_hints(func)["return"] + origin = get_origin(return_type) + + if origin is ExtendedList: + assert get_args(return_type) == (ExtendedDict,), f"{module_name}.{func.__name__}" + continue + + assert return_type is ExtendedDict, f"{module_name}.{func.__name__}" diff --git a/tests/connectors/test_vault_connector.py b/tests/connectors/test_vault_connector.py index 38a6db8..26a0cc3 100644 --- a/tests/connectors/test_vault_connector.py +++ b/tests/connectors/test_vault_connector.py @@ -1,3 +1,4 @@ +# ruff: noqa: I001 """Tests for VaultConnector.""" from __future__ import annotations @@ -7,11 +8,23 @@ import pytest +pytest.importorskip("hvac") + from hvac.exceptions import VaultError +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString, extend_data from extended_data.connectors.vault import VaultConnector +def _logged_text(logger: MagicMock) -> str: + """Return concatenated mock logger messages.""" + return "\n".join( + str(arg) + for call in logger.method_calls + for arg in call.args + ) + + class TestVaultConnector: """Test suite for VaultConnector.""" @@ -45,6 +58,60 @@ def test_vault_client_with_token(self, mock_hvac_class, base_connector_kwargs): assert client == mock_client mock_hvac_class.assert_called() + @patch("extended_data.connectors.vault.hvac.Client") + def test_vault_client_token_failure_redacts_without_traceback(self, mock_hvac_class, base_connector_kwargs): + """Token client initialization failures should avoid traceback diagnostics.""" + mock_hvac_class.side_effect = VaultError("token failure test-token Authorization: Bearer raw_token") + connector = VaultConnector( + vault_url="https://vault.example.com", vault_token="test-token", **base_connector_kwargs + ) + + with pytest.raises(RuntimeError, match="Vault authentication failed"): + _ = connector.vault_client + + logs = _logged_text(connector.logger) + assert "test-token" not in logs + assert "raw_token" not in logs + assert "[REDACTED]" in logs + connector.logger.exception.assert_not_called() + assert all("exc_info" not in logged_call.kwargs for logged_call in connector.logger.method_calls) + + @patch("extended_data.connectors.vault.hvac.Client") + def test_vault_client_approle_failure_redacts_without_raw_cause(self, mock_hvac_class, base_connector_kwargs): + """AppRole authentication failures should raise a redacted RuntimeError.""" + mock_client = MagicMock() + mock_client.is_authenticated.return_value = False + mock_client.auth.approle.login.side_effect = VaultError( + "approle failed role-raw secret-raw token=raw-token" + ) + mock_hvac_class.return_value = mock_client + + connector = VaultConnector(vault_url="https://vault.example.com", **base_connector_kwargs) + + def get_input(name, **kwargs): + values = { + "VAULT_NAMESPACE": None, + "VAULT_TOKEN": None, + "VAULT_APPROLE_PATH": "approle", + "VAULT_ROLE_ID": "role-raw", + "VAULT_SECRET_ID": "secret-raw", + } + return values.get(name, kwargs.get("default")) + + connector.get_input = MagicMock(side_effect=get_input) + + with pytest.raises(RuntimeError) as exc_info: + _ = connector.vault_client + + diagnostics = _logged_text(connector.logger) + str(exc_info.value) + assert "role-raw" not in diagnostics + assert "secret-raw" not in diagnostics + assert "raw-token" not in diagnostics + assert "[REDACTED]" in diagnostics + assert exc_info.value.__cause__ is None + connector.logger.exception.assert_not_called() + assert all("exc_info" not in logged_call.kwargs for logged_call in connector.logger.method_calls) + def test_is_token_valid(self, base_connector_kwargs): """Test token validity check.""" connector = VaultConnector( @@ -112,6 +179,9 @@ def read_side_effect(path, mount_point): secrets = connector.list_secrets() + assert isinstance(secrets, ExtendedDict) + assert isinstance(secrets["shared"], ExtendedDict) + assert isinstance(secrets["shared"]["value"], ExtendedString) assert secrets == { "shared": {"value": "shared"}, "finance/dev": {"value": "dev"}, @@ -133,12 +203,35 @@ def test_list_secrets_handles_invalid_root(self, base_connector_kwargs): secrets = connector.list_secrets(root_path="does/not/exist") + assert isinstance(secrets, ExtendedDict) assert secrets == {} mock_client.secrets.kv.v2.list_secrets.assert_called_once_with( path="does/not/exist", mount_point="secret", ) + def test_list_secrets_redacts_vault_error_logs(self, base_connector_kwargs): + """Vault list failures should not log raw secret-bearing exception text.""" + connector = VaultConnector( + vault_url="https://vault.example.com", vault_token="test-token", **base_connector_kwargs + ) + + mock_client = MagicMock() + connector._vault_client = mock_client + connector._vault_token_expiration = datetime(2099, 1, 1, tzinfo=timezone.utc) + mock_client.secrets.kv.v2.list_secrets.side_effect = VaultError( + "denied password=hunter2 Authorization: Bearer raw_token" + ) + + secrets = connector.list_secrets(root_path="does/not/exist") + + logs = _logged_text(connector.logger) + assert secrets == {} + assert "does/not/exist" not in logs + assert "hunter2" not in logs + assert "raw_token" not in logs + assert "[REDACTED]" in logs + def test_list_secrets_rejects_path_traversal(self, base_connector_kwargs): """Ensure list_secrets rejects path traversal in root_path.""" connector = VaultConnector( @@ -168,11 +261,22 @@ def test_list_aws_iam_roles_filters_prefix(self, base_connector_kwargs): mock_client.secrets.aws.list_roles.return_value = {"data": {"keys": ["prod-sync", "dev-sync"]}} - roles = connector.list_aws_iam_roles(name_prefix="prod") + roles = connector.list_aws_iam_roles(prefix="prod") + assert isinstance(roles, ExtendedList) + assert isinstance(roles[0], ExtendedString) assert roles == ["prod-sync"] mock_client.secrets.aws.list_roles.assert_called_once_with(mount_point="aws") + def test_list_aws_iam_roles_does_not_preserve_name_prefix_alias(self, base_connector_kwargs): + """Clean major-version surface should not preserve the old name_prefix keyword.""" + connector = VaultConnector( + vault_url="https://vault.example.com", vault_token="test-token", **base_connector_kwargs + ) + + with pytest.raises(TypeError, match="name_prefix"): + connector.list_aws_iam_roles(name_prefix="prod") # type: ignore[call-arg] + def test_list_aws_iam_roles_handles_errors(self, base_connector_kwargs): """Vault errors while listing roles should return an empty list.""" connector = VaultConnector( @@ -187,6 +291,7 @@ def test_list_aws_iam_roles_handles_errors(self, base_connector_kwargs): roles = connector.list_aws_iam_roles() + assert isinstance(roles, ExtendedList) assert roles == [] def test_get_aws_iam_role_returns_data(self, base_connector_kwargs): @@ -203,6 +308,8 @@ def test_get_aws_iam_role_returns_data(self, base_connector_kwargs): role_data = connector.get_aws_iam_role(role_name="prod") + assert isinstance(role_data, ExtendedDict) + assert isinstance(role_data["arn"], ExtendedString) assert role_data == {"arn": "arn:aws:iam::123:role/prod"} mock_client.secrets.aws.read_role.assert_called_once_with(name="prod", mount_point="aws") @@ -220,6 +327,28 @@ def test_get_aws_iam_role_handles_errors(self, base_connector_kwargs): assert connector.get_aws_iam_role(role_name="missing") is None + def test_get_secret_matcher_logs_redact_secret_values(self, base_connector_kwargs): + """Matcher-success logs should not expose matched Vault secret values.""" + connector = VaultConnector( + vault_url="https://vault.example.com", vault_token="test-token", **base_connector_kwargs + ) + + mock_client = MagicMock() + connector._vault_client = mock_client + connector._vault_token_expiration = datetime(2099, 1, 1, tzinfo=timezone.utc) + connector.list_secrets = MagicMock(return_value=extend_data({"prod/db": {}})) # type: ignore[method-assign] + mock_client.secrets.kv.v2.read_secret_version.return_value = { + "data": {"data": {"password": "hunter2", "username": "admin"}} + } + + secret = connector.get_secret(path="prod", matchers={"password": "hunter2"}) + + logs = _logged_text(connector.logger) + assert secret == {"password": "hunter2", "username": "admin"} + assert "prod/db" not in logs + assert "hunter2" not in logs + assert "Matched [REDACTED] on matcher password" in logs + def test_generate_aws_credentials_success(self, base_connector_kwargs): """generate_aws_credentials should return the generated credential payload.""" connector = VaultConnector( @@ -236,6 +365,8 @@ def test_generate_aws_credentials_success(self, base_connector_kwargs): credentials = connector.generate_aws_credentials(role_name="prod", ttl="1h", credential_type="sts") + assert isinstance(credentials, ExtendedDict) + assert isinstance(credentials["access_key"], ExtendedString) assert credentials["access_key"] == "AKIA" mock_client.secrets.aws.generate_credentials.assert_called_once_with( name="prod", @@ -258,3 +389,54 @@ def test_generate_aws_credentials_error(self, base_connector_kwargs): with pytest.raises(RuntimeError): connector.generate_aws_credentials(role_name="prod") + + def test_write_secret_failure_redacts_without_traceback(self, base_connector_kwargs): + """Vault write failures should not expose paths, values, or tracebacks.""" + connector = VaultConnector( + vault_url="https://vault.example.com", vault_token="test-token", **base_connector_kwargs + ) + + mock_client = MagicMock() + connector._vault_client = mock_client + connector._vault_token_expiration = datetime(2099, 1, 1, tzinfo=timezone.utc) + mock_client.secrets.kv.v2.create_or_update_secret.side_effect = VaultError( + "write failed at prod/db password=hunter2 token=raw-token" + ) + + assert connector.write_secret("prod/db", {"password": "hunter2"}) is False + + logs = _logged_text(connector.logger) + assert "prod/db" not in logs + assert "hunter2" not in logs + assert "raw-token" not in logs + assert "[REDACTED]" in logs + connector.logger.exception.assert_not_called() + assert all("exc_info" not in logged_call.kwargs for logged_call in connector.logger.method_calls) + + def test_generate_aws_credentials_redacts_error_diagnostics(self, base_connector_kwargs): + """Vault credential failures should redact role names and exception payloads.""" + connector = VaultConnector( + vault_url="https://vault.example.com", vault_token="test-token", **base_connector_kwargs + ) + + mock_client = MagicMock() + connector._vault_client = mock_client + connector._vault_token_expiration = datetime(2099, 1, 1, tzinfo=timezone.utc) + mock_client.secrets.aws.generate_credentials.side_effect = VaultError( + "denied api_key=key_123 Authorization: Bearer raw_token" + ) + + with pytest.raises(RuntimeError) as exc_info: + connector.generate_aws_credentials(role_name="prod password=hunter2") + + logs = _logged_text(connector.logger) + message = str(exc_info.value) + assert "hunter2" not in logs + assert "key_123" not in logs + assert "raw_token" not in logs + assert "hunter2" not in message + assert "[REDACTED]" in logs + assert "[REDACTED]" in message + assert exc_info.value.__cause__ is None + connector.logger.exception.assert_not_called() + assert all("exc_info" not in logged_call.kwargs for logged_call in connector.logger.method_calls) diff --git a/tests/connectors/test_vault_tools.py b/tests/connectors/test_vault_tools.py index 3612c92..ca7a284 100644 --- a/tests/connectors/test_vault_tools.py +++ b/tests/connectors/test_vault_tools.py @@ -2,15 +2,30 @@ from __future__ import annotations +import importlib.util + from unittest.mock import MagicMock, patch import pytest +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString, extend_data + # Patch target for VaultConnector - must patch where it's used (in tools.py), not where it's defined VAULT_CONNECTOR_PATCH = "extended_data.connectors.vault.VaultConnector" +def test_vault_connector_requires_hvac_when_constructed_without_extra() -> None: + """Vault tool metadata imports without hvac, but the connector still requires the extra.""" + if importlib.util.find_spec("hvac") is not None: + pytest.skip("hvac is installed") + + from extended_data.connectors.vault import VaultConnector + + with pytest.raises(ImportError, match=r"extended-data\[vault\]"): + VaultConnector(from_environment=False) + + class TestVaultToolDefinitions: """Test tool definitions and metadata.""" @@ -47,14 +62,20 @@ def test_list_secrets_basic(self, mock_connector_class): from extended_data.connectors.vault.tools import list_secrets mock_connector = MagicMock() - mock_connector.list_secrets.return_value = { - "app/db-password": {"username": "admin", "password": "secret123"}, - "app/api-key": {"key": "abc123xyz"}, - } + mock_connector.list_secrets.return_value = extend_data( + { + "app/db-password": {"username": "admin", "password": "secret123"}, + "app/api-key": {"key": "abc123xyz"}, + } + ) mock_connector_class.return_value = mock_connector result = list_secrets() + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) + assert isinstance(result[0]["path"], ExtendedString) + assert isinstance(result[0]["data"], ExtendedDict) assert len(result) == 2 assert result[0]["path"] == "app/db-password" assert result[0]["mount_point"] == "secret" @@ -133,6 +154,9 @@ def test_read_secret_found(self, mock_connector_class): result = read_secret("app/db-password") + assert isinstance(result, ExtendedDict) + assert isinstance(result["path"], ExtendedString) + assert isinstance(result["data"], ExtendedDict) assert result["path"] == "app/db-password" assert result["mount_point"] == "secret" assert result["data"]["username"] == "admin" @@ -150,6 +174,8 @@ def test_read_secret_not_found(self, mock_connector_class): result = read_secret("app/missing-secret") + assert isinstance(result, ExtendedDict) + assert isinstance(result["data"], ExtendedDict) assert result["path"] == "app/missing-secret" assert result["mount_point"] == "secret" assert result["data"] == {} @@ -194,14 +220,12 @@ def test_get_tools_strands(self): assert len(tools) > 0 assert all(callable(t) for t in tools) - def test_get_tools_functions(self): - """Test get_tools with functions framework (alias for strands).""" + def test_get_tools_rejects_functions_alias(self): + """Plain-function tools should use the canonical strands framework name.""" from extended_data.connectors.vault.tools import get_tools - tools = get_tools(framework="functions") - - assert len(tools) > 0 - assert all(callable(t) for t in tools) + with pytest.raises(ValueError, match="Unknown framework"): + get_tools(framework="functions") def test_get_tools_invalid_framework(self): """Test get_tools with invalid framework raises ValueError.""" diff --git a/tests/connectors/test_zoom_connector.py b/tests/connectors/test_zoom_connector.py index d0b2aad..3c2f990 100644 --- a/tests/connectors/test_zoom_connector.py +++ b/tests/connectors/test_zoom_connector.py @@ -2,11 +2,45 @@ from __future__ import annotations +import json + from unittest.mock import MagicMock, patch import pytest +import requests from extended_data.connectors.zoom import ZoomConnector +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString + + +def _logged_text(logger: MagicMock) -> str: + """Return concatenated mock logger messages.""" + return "\n".join(str(arg) for call in logger.method_calls for arg in call.args) + + +def _json_response(payload: object) -> MagicMock: + """Build a requests-like response whose JSON must be decoded from content.""" + response = MagicMock() + response.content = json.dumps(payload).encode() + response.text = response.content.decode() + response.json.side_effect = AssertionError("Zoom responses must be decoded from content bytes") + response.raise_for_status = MagicMock() + return response + + +def _text_response(text: str) -> MagicMock: + """Build a requests-like response with invalid/non-JSON body text.""" + response = MagicMock() + response.content = text.encode() + response.text = text + response.json.side_effect = AssertionError("Zoom responses must be decoded from content bytes") + response.raise_for_status = MagicMock() + return response + + +def _token_response(token: str = "test-token") -> MagicMock: + """Build a successful Zoom OAuth response mock.""" + return _json_response({"access_token": token}) class TestZoomConnector: @@ -28,9 +62,7 @@ def test_init(self, base_connector_kwargs): @patch("extended_data.connectors.zoom.requests.post") def test_get_access_token_success(self, mock_post, base_connector_kwargs): """Test successful access token retrieval.""" - mock_response = MagicMock() - mock_response.json.return_value = {"access_token": "test-access-token"} - mock_response.raise_for_status = MagicMock() + mock_response = _json_response({"access_token": "test-access-token"}) mock_post.return_value = mock_response connector = ZoomConnector( @@ -47,9 +79,36 @@ def test_get_access_token_success(self, mock_post, base_connector_kwargs): @patch("extended_data.connectors.zoom.requests.post") def test_get_access_token_failure(self, mock_post, base_connector_kwargs): """Test failed access token retrieval.""" - import requests + mock_post.side_effect = requests.exceptions.RequestException( + "Connection error test-account-id client_secret=raw-secret" + ) + + connector = ZoomConnector( + client_id="test-client-id", + client_secret="test-client-secret", + account_id="test-account-id", + **base_connector_kwargs, + ) + + with pytest.raises(RuntimeError, match="Failed to get Zoom access token") as exc_info: + connector.get_access_token() + + message = str(exc_info.value) + assert "test-account-id" not in message + assert "test-client-secret" not in message + assert "raw-secret" not in message + assert "[REDACTED]" in message + assert exc_info.value.__cause__ is None - mock_post.side_effect = requests.exceptions.RequestException("Connection error") + @patch("extended_data.connectors.zoom.requests.post") + def test_get_access_token_malformed_response_is_redacted(self, mock_post, base_connector_kwargs): + """Missing token responses should fail loudly without exposing OAuth credentials.""" + mock_response = _json_response({ + "password": "hunter2", + "authorization": "Bearer raw_token", + "account_id": "test-account-id", + }) + mock_post.return_value = mock_response connector = ZoomConnector( client_id="test-client-id", @@ -58,27 +117,77 @@ def test_get_access_token_failure(self, mock_post, base_connector_kwargs): **base_connector_kwargs, ) - with pytest.raises(RuntimeError, match="Failed to get Zoom access token"): + with pytest.raises(RuntimeError, match="Unexpected Zoom access token response") as exc_info: connector.get_access_token() + message = str(exc_info.value) + for raw_value in ["hunter2", "raw_token", "test-client-id", "test-client-secret", "test-account-id"]: + assert raw_value not in message + assert "[REDACTED]" in message + + @patch("extended_data.connectors.zoom.requests.get") + @patch("extended_data.connectors.zoom.requests.post") + def test_list_users_redacts_request_failure_details(self, mock_post, mock_get, base_connector_kwargs): + """Zoom list failures should not expose raw secret-bearing exception text.""" + mock_post.return_value = _token_response() + mock_get.side_effect = requests.exceptions.RequestException( + "status=401 password=hunter2 Authorization: Bearer raw_token" + ) + + connector = ZoomConnector( + client_id="test-client-id", + client_secret="test-client-secret", + account_id="test-account-id", + **base_connector_kwargs, + ) + + with pytest.raises(RuntimeError) as exc_info: + connector.list_users() + + message = str(exc_info.value) + assert "hunter2" not in message + assert "raw_token" not in message + assert "[REDACTED]" in message + assert exc_info.value.__cause__ is None + @patch("extended_data.connectors.zoom.requests.get") @patch("extended_data.connectors.zoom.requests.post") - def test_get_zoom_users(self, mock_post, mock_get, base_connector_kwargs): - """Test getting Zoom users.""" - mock_token_response = MagicMock() - mock_token_response.json.return_value = {"access_token": "test-token"} - mock_token_response.raise_for_status = MagicMock() - mock_post.return_value = mock_token_response + def test_list_users_malformed_response_is_redacted(self, mock_post, mock_get, base_connector_kwargs): + """Malformed user list responses should not return partial or raw payloads.""" + mock_post.return_value = _token_response() + mock_users_response = _json_response({ + "users": [{"password": "hunter2", "authorization": "Bearer raw_token"}] + }) + mock_get.return_value = mock_users_response - mock_users_response = MagicMock() - mock_users_response.json.return_value = { + connector = ZoomConnector( + client_id="test-client-id", + client_secret="test-client-secret", + account_id="test-account-id", + **base_connector_kwargs, + ) + + with pytest.raises(RuntimeError, match="Unexpected Zoom users response") as exc_info: + connector.list_users() + + message = str(exc_info.value) + assert "hunter2" not in message + assert "raw_token" not in message + assert "[REDACTED]" in message + + @patch("extended_data.connectors.zoom.requests.get") + @patch("extended_data.connectors.zoom.requests.post") + def test_list_users(self, mock_post, mock_get, base_connector_kwargs): + """Test listing Zoom users.""" + mock_post.return_value = _token_response() + + mock_users_response = _json_response({ "users": [ {"email": "user1@example.com", "id": "123", "first_name": "User", "last_name": "One"}, {"email": "user2@example.com", "id": "456", "first_name": "User", "last_name": "Two"}, ], "next_page_token": None, - } - mock_users_response.raise_for_status = MagicMock() + }) mock_get.return_value = mock_users_response connector = ZoomConnector( @@ -88,17 +197,29 @@ def test_get_zoom_users(self, mock_post, mock_get, base_connector_kwargs): **base_connector_kwargs, ) - users = connector.get_zoom_users() + users = connector.list_users() + assert isinstance(users, ExtendedDict) + assert isinstance(users["user1@example.com"], ExtendedDict) + assert isinstance(users["user1@example.com"]["first_name"], ExtendedString) assert "user1@example.com" in users assert "user2@example.com" in users assert len(users) == 2 + def test_get_zoom_users_alias_is_not_preserved(self, base_connector_kwargs): + """The clean major version should expose only the canonical list_users method.""" + connector = ZoomConnector( + client_id="test-client-id", + client_secret="test-client-secret", + account_id="test-account-id", + **base_connector_kwargs, + ) + + assert not hasattr(connector, "get_zoom_users") + @patch("extended_data.connectors.zoom.requests.post") def test_create_zoom_user(self, mock_post, base_connector_kwargs): """Test creating a Zoom user.""" - mock_token_response = MagicMock() - mock_token_response.json.return_value = {"access_token": "test-token"} - mock_token_response.raise_for_status = MagicMock() + mock_token_response = _token_response() mock_create_response = MagicMock() mock_create_response.raise_for_status = MagicMock() @@ -116,24 +237,14 @@ def test_create_zoom_user(self, mock_post, base_connector_kwargs): assert result is True assert mock_post.call_count == 2 - @patch("extended_data.connectors.zoom.requests.get") + @patch("extended_data.connectors.zoom.requests.delete") @patch("extended_data.connectors.zoom.requests.post") - def test_list_users(self, mock_post, mock_get, base_connector_kwargs): - """Test list_users method (alias for get_zoom_users).""" - mock_token_response = MagicMock() - mock_token_response.json.return_value = {"access_token": "test-token"} - mock_token_response.raise_for_status = MagicMock() - mock_post.return_value = mock_token_response - - mock_users_response = MagicMock() - mock_users_response.json.return_value = { - "users": [ - {"email": "user1@example.com", "id": "123"}, - ], - "next_page_token": None, - } - mock_users_response.raise_for_status = MagicMock() - mock_get.return_value = mock_users_response + def test_remove_zoom_user_redacts_error_state_and_logs(self, mock_post, mock_delete, base_connector_kwargs): + """Zoom mutation failures should redact user IDs and exception secrets.""" + mock_post.return_value = _token_response() + mock_delete.side_effect = requests.exceptions.RequestException( + "failed for private-user@example.com?access_token=raw_token" + ) connector = ZoomConnector( client_id="test-client-id", @@ -142,26 +253,54 @@ def test_list_users(self, mock_post, mock_get, base_connector_kwargs): **base_connector_kwargs, ) - users = connector.list_users() - assert "user1@example.com" in users + connector.remove_zoom_user("private-user@example.com") + + diagnostics = "\n".join(connector.errors) + _logged_text(connector.logger) + assert "private-user@example.com" not in diagnostics + assert "raw_token" not in diagnostics + assert "[REDACTED]" in diagnostics + connector.logger.exception.assert_not_called() + assert all("exc_info" not in logged_call.kwargs for logged_call in connector.logger.method_calls) + + @patch("extended_data.connectors.zoom.requests.post") + def test_create_zoom_user_redacts_error_state_and_logs(self, mock_post, base_connector_kwargs): + """Zoom create failures should redact user PII and avoid traceback logs.""" + mock_token_response = _token_response() + mock_post.side_effect = [ + mock_token_response, + requests.exceptions.RequestException("failed Jane SecretUser newuser@example.com token=raw-token"), + ] + + connector = ZoomConnector( + client_id="test-client-id", + client_secret="test-client-secret", + account_id="test-account-id", + **base_connector_kwargs, + ) + + assert connector.create_zoom_user("newuser@example.com", "Jane", "SecretUser") is False + + diagnostics = "\n".join(connector.errors) + _logged_text(connector.logger) + assert "newuser@example.com" not in diagnostics + assert "Jane" not in diagnostics + assert "SecretUser" not in diagnostics + assert "raw-token" not in diagnostics + assert "[REDACTED]" in diagnostics + connector.logger.exception.assert_not_called() + assert all("exc_info" not in logged_call.kwargs for logged_call in connector.logger.method_calls) @patch("extended_data.connectors.zoom.requests.get") @patch("extended_data.connectors.zoom.requests.post") def test_get_user(self, mock_post, mock_get, base_connector_kwargs): """Test getting a specific user.""" - mock_token_response = MagicMock() - mock_token_response.json.return_value = {"access_token": "test-token"} - mock_token_response.raise_for_status = MagicMock() - mock_post.return_value = mock_token_response + mock_post.return_value = _token_response() - mock_user_response = MagicMock() - mock_user_response.json.return_value = { + mock_user_response = _json_response({ "id": "123", "email": "user1@example.com", "first_name": "User", "last_name": "One", - } - mock_user_response.raise_for_status = MagicMock() + }) mock_get.return_value = mock_user_response connector = ZoomConnector( @@ -172,26 +311,72 @@ def test_get_user(self, mock_post, mock_get, base_connector_kwargs): ) user = connector.get_user("user1@example.com") + assert isinstance(user, ExtendedDict) + assert isinstance(user["first_name"], ExtendedString) assert user["email"] == "user1@example.com" assert user["id"] == "123" + @patch("extended_data.connectors.zoom.requests.get") + @patch("extended_data.connectors.zoom.requests.post") + def test_get_user_redacts_identifier_and_secret_details(self, mock_post, mock_get, base_connector_kwargs): + """Zoom lookup failures should not echo user identifiers or secrets.""" + mock_post.return_value = _token_response() + mock_get.side_effect = requests.exceptions.RequestException( + "404 for user1@example.com and user1%40example.com client_secret=s3cr3t" + ) + + connector = ZoomConnector( + client_id="test-client-id", + client_secret="test-client-secret", + account_id="test-account-id", + **base_connector_kwargs, + ) + + with pytest.raises(RuntimeError) as exc_info: + connector.get_user("user1@example.com") + + message = str(exc_info.value) + assert "user1@example.com" not in message + assert "user1%40example.com" not in message + assert "s3cr3t" not in message + assert "[REDACTED]" in message + assert exc_info.value.__cause__ is None + + @patch("extended_data.connectors.zoom.requests.get") + @patch("extended_data.connectors.zoom.requests.post") + def test_get_user_malformed_response_is_redacted(self, mock_post, mock_get, base_connector_kwargs): + """Zoom user lookups should reject non-object payloads without leaking identifiers.""" + mock_post.return_value = _token_response() + mock_user_response = _json_response(["private-user@example.com", {"password": "hunter2"}]) + mock_get.return_value = mock_user_response + + connector = ZoomConnector( + client_id="test-client-id", + client_secret="test-client-secret", + account_id="test-account-id", + **base_connector_kwargs, + ) + + with pytest.raises(RuntimeError, match="Unexpected Zoom user response") as exc_info: + connector.get_user("private-user@example.com") + + message = str(exc_info.value) + assert "private-user@example.com" not in message + assert "hunter2" not in message + assert "[REDACTED]" in message + @patch("extended_data.connectors.zoom.requests.get") @patch("extended_data.connectors.zoom.requests.post") def test_list_meetings(self, mock_post, mock_get, base_connector_kwargs): """Test listing meetings for a user.""" - mock_token_response = MagicMock() - mock_token_response.json.return_value = {"access_token": "test-token"} - mock_token_response.raise_for_status = MagicMock() - mock_post.return_value = mock_token_response + mock_post.return_value = _token_response() - mock_meetings_response = MagicMock() - mock_meetings_response.json.return_value = { + mock_meetings_response = _json_response({ "meetings": [ {"id": "111", "topic": "Team Meeting"}, {"id": "222", "topic": "Client Call"}, ] - } - mock_meetings_response.raise_for_status = MagicMock() + }) mock_get.return_value = mock_meetings_response connector = ZoomConnector( @@ -202,25 +387,73 @@ def test_list_meetings(self, mock_post, mock_get, base_connector_kwargs): ) meetings = connector.list_meetings("user1@example.com") + assert isinstance(meetings, ExtendedList) + assert isinstance(meetings[0], ExtendedDict) assert len(meetings) == 2 assert meetings[0]["id"] == "111" + @patch("extended_data.connectors.zoom.requests.get") + @patch("extended_data.connectors.zoom.requests.post") + def test_list_meetings_redacts_identifier_and_secret_details(self, mock_post, mock_get, base_connector_kwargs): + """Zoom meeting list failures should not chain raw user identifiers.""" + mock_post.return_value = _token_response() + mock_get.side_effect = requests.exceptions.RequestException( + "failed for private-user@example.com type=scheduled token=raw-token" + ) + + connector = ZoomConnector( + client_id="test-client-id", + client_secret="test-client-secret", + account_id="test-account-id", + **base_connector_kwargs, + ) + + with pytest.raises(RuntimeError) as exc_info: + connector.list_meetings("private-user@example.com") + + message = str(exc_info.value) + assert "private-user@example.com" not in message + assert "raw-token" not in message + assert "[REDACTED]" in message + assert exc_info.value.__cause__ is None + + @patch("extended_data.connectors.zoom.requests.get") + @patch("extended_data.connectors.zoom.requests.post") + def test_list_meetings_malformed_response_is_redacted(self, mock_post, mock_get, base_connector_kwargs): + """Zoom meeting list responses should preserve the ExtendedList contract.""" + mock_post.return_value = _token_response() + mock_meetings_response = _json_response({ + "meetings": [{"id": "111"}, "password=hunter2 Authorization: Bearer raw_token"] + }) + mock_get.return_value = mock_meetings_response + + connector = ZoomConnector( + client_id="test-client-id", + client_secret="test-client-secret", + account_id="test-account-id", + **base_connector_kwargs, + ) + + with pytest.raises(RuntimeError, match="Unexpected Zoom meetings response") as exc_info: + connector.list_meetings("private-user@example.com") + + message = str(exc_info.value) + assert "private-user@example.com" not in message + assert "hunter2" not in message + assert "raw_token" not in message + assert "[REDACTED]" in message + @patch("extended_data.connectors.zoom.requests.get") @patch("extended_data.connectors.zoom.requests.post") def test_get_meeting(self, mock_post, mock_get, base_connector_kwargs): """Test getting a specific meeting.""" - mock_token_response = MagicMock() - mock_token_response.json.return_value = {"access_token": "test-token"} - mock_token_response.raise_for_status = MagicMock() - mock_post.return_value = mock_token_response + mock_post.return_value = _token_response() - mock_meeting_response = MagicMock() - mock_meeting_response.json.return_value = { + mock_meeting_response = _json_response({ "id": "111", "topic": "Team Meeting", "start_time": "2024-01-15T10:00:00Z", - } - mock_meeting_response.raise_for_status = MagicMock() + }) mock_get.return_value = mock_meeting_response connector = ZoomConnector( @@ -231,5 +464,56 @@ def test_get_meeting(self, mock_post, mock_get, base_connector_kwargs): ) meeting = connector.get_meeting("111") + assert isinstance(meeting, ExtendedDict) + assert isinstance(meeting["topic"], ExtendedString) assert meeting["id"] == "111" assert meeting["topic"] == "Team Meeting" + + @patch("extended_data.connectors.zoom.requests.get") + @patch("extended_data.connectors.zoom.requests.post") + def test_get_meeting_redacts_identifier_and_secret_details(self, mock_post, mock_get, base_connector_kwargs): + """Zoom meeting lookup failures should not chain raw meeting identifiers.""" + mock_post.return_value = _token_response() + mock_get.side_effect = requests.exceptions.RequestException("meeting private-meeting token=raw-token") + + connector = ZoomConnector( + client_id="test-client-id", + client_secret="test-client-secret", + account_id="test-account-id", + **base_connector_kwargs, + ) + + with pytest.raises(RuntimeError) as exc_info: + connector.get_meeting("private-meeting") + + message = str(exc_info.value) + assert "private-meeting" not in message + assert "raw-token" not in message + assert "[REDACTED]" in message + assert exc_info.value.__cause__ is None + + @patch("extended_data.connectors.zoom.requests.get") + @patch("extended_data.connectors.zoom.requests.post") + def test_get_meeting_json_parse_error_is_redacted(self, mock_post, mock_get, base_connector_kwargs): + """Zoom JSON parse failures should not expose raw meeting IDs or parser text.""" + mock_post.return_value = _token_response() + mock_meeting_response = _text_response( + "bad meeting private-meeting password=hunter2 Authorization: Bearer raw_token" + ) + mock_get.return_value = mock_meeting_response + + connector = ZoomConnector( + client_id="test-client-id", + client_secret="test-client-secret", + account_id="test-account-id", + **base_connector_kwargs, + ) + + with pytest.raises(RuntimeError, match="Unexpected Zoom meeting response") as exc_info: + connector.get_meeting("private-meeting") + + message = str(exc_info.value) + assert "private-meeting" not in message + assert "hunter2" not in message + assert "raw_token" not in message + assert "[REDACTED]" in message diff --git a/tests/connectors/test_zoom_tools.py b/tests/connectors/test_zoom_tools.py index 0455602..74d28a3 100644 --- a/tests/connectors/test_zoom_tools.py +++ b/tests/connectors/test_zoom_tools.py @@ -4,6 +4,8 @@ import pytest +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString + CONNECTOR_PATCH = "extended_data.connectors.zoom.ZoomConnector" @@ -76,9 +78,12 @@ def test_list_users_basic(self, mock_connector_class): result = list_users() + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) assert len(result) == 2 assert result[0]["email"] == "user1@example.com" assert result[0]["id"] == "123" + assert isinstance(result[0]["first_name"], ExtendedString) assert result[0]["first_name"] == "John" assert result[1]["email"] == "user2@example.com" @@ -142,6 +147,8 @@ def test_get_user_basic(self, mock_connector_class): result = get_user("user1@example.com") + assert isinstance(result, ExtendedDict) + assert isinstance(result["first_name"], ExtendedString) assert result["email"] == "user1@example.com" assert result["id"] == "123" assert result["first_name"] == "John" @@ -205,6 +212,8 @@ def test_list_meetings_basic(self, mock_connector_class): result = list_meetings("user1@example.com") + assert isinstance(result, ExtendedList) + assert isinstance(result[0], ExtendedDict) assert len(result) == 2 assert result[0]["id"] == "111" assert result[0]["topic"] == "Team Meeting" @@ -274,6 +283,8 @@ def test_get_meeting_basic(self, mock_connector_class): result = get_meeting("111") + assert isinstance(result, ExtendedDict) + assert isinstance(result["topic"], ExtendedString) assert result["id"] == "111" assert result["topic"] == "Team Meeting" assert result["host_email"] == "host@example.com" @@ -299,13 +310,12 @@ def test_get_tools_strands(self): assert len(tools) == 4 assert all(callable(t) for t in tools) - def test_get_tools_functions(self): - """Test get_tools with functions framework (alias for strands).""" + def test_get_tools_rejects_functions_alias(self): + """Plain-function tools should use the canonical strands framework name.""" from extended_data.connectors.zoom.tools import get_tools - tools = get_tools(framework="functions") - assert len(tools) == 4 - assert all(callable(t) for t in tools) + with pytest.raises(ValueError, match="Unknown framework"): + get_tools(framework="functions") def test_get_tools_invalid_framework(self): """Test get_tools with invalid framework raises error.""" diff --git a/tests/core/test_base64_utils.py b/tests/core/test_base64_utils.py index 886411d..5e7c3b5 100644 --- a/tests/core/test_base64_utils.py +++ b/tests/core/test_base64_utils.py @@ -22,8 +22,9 @@ import pytest -from extended_data.base64_utils import base64_decode, base64_encode -from extended_data.export_utils import wrap_raw_data_for_export +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString +from extended_data.io.base64 import base64_decode, base64_encode +from extended_data.io.exporters import wrap_raw_data_for_export def test_base64_encode_string() -> None: @@ -178,6 +179,26 @@ def test_base64_decode_with_tf_alias_unwrap() -> None: assert result == {"locals": [{"region": "us-east-1"}]} +def test_base64_decode_returns_extended_containers_by_default() -> None: + """Base64 unwrapping enters the Tier 2 container layer by default.""" + encoded_data = base64.b64encode(b'{"service": {"name": "api"}, "ports": [8080]}').decode("utf-8") + result = base64_decode(encoded_data, unwrap_raw_data=True, encoding="json") + + assert isinstance(result, ExtendedDict) + assert isinstance(result["service"], ExtendedDict) + assert isinstance(result["service"]["name"], ExtendedString) + assert isinstance(result["ports"], ExtendedList) + + +def test_base64_decode_can_return_builtin_containers() -> None: + """Base64 unwrapping can explicitly return plain Python containers.""" + encoded_data = base64.b64encode(b'{"service": {"name": "api"}}').decode("utf-8") + result = base64_decode(encoded_data, unwrap_raw_data=True, encoding="json", as_extended=False) + + assert isinstance(result, dict) + assert not isinstance(result, ExtendedDict) + + def test_base64_decode_rejects_non_utf8_when_unwrapping() -> None: """Raise a clear error when wrapped decoding requires non-text bytes to be parsed.""" encoded_data = base64.b64encode(b"\xff\xfe").decode("utf-8") diff --git a/tests/core/test_containers.py b/tests/core/test_containers.py new file mode 100644 index 0000000..ca33595 --- /dev/null +++ b/tests/core/test_containers.py @@ -0,0 +1,486 @@ +"""Tests for Tier 2 extended containers.""" + +from __future__ import annotations + +import datetime +import json + +from collections import UserDict, UserList, UserString +from collections.abc import MutableSet +from pathlib import Path +from typing import Any + +import extended_data + +from extended_data.containers import ( + ExtendedDict, + ExtendedList, + ExtendedSet, + ExtendedString, + ExtendedTuple, + extend_data, + to_builtin, +) + + +def test_tier2_containers_inherit_expected_python_bases() -> None: + """Tier 2 classes should be real extended primitives, not detached facades.""" + assert issubclass(ExtendedString, UserString) + assert issubclass(ExtendedDict, UserDict) + assert issubclass(ExtendedList, UserList) + assert issubclass(ExtendedTuple, tuple) + assert issubclass(ExtendedSet, MutableSet) + assert isinstance(ExtendedString("api"), UserString) + assert isinstance(ExtendedDict({"service": "api"}), UserDict) + assert isinstance(ExtendedList(["api"]), UserList) + assert isinstance(ExtendedTuple(("api",)), tuple) + assert isinstance(ExtendedSet({"api"}), MutableSet) + + +def test_extended_string_chains_primitive_transforms() -> None: + """ExtendedString composes Tier 1 string primitives.""" + value = ExtendedString("API Response Value") + partitioned = ExtendedString("api.gateway.worker").partition(".") + right_partitioned = ExtendedString("api.gateway.worker").rpartition(".") + split = ExtendedString("api,gateway,worker").split(",") + right_split = ExtendedString("api,gateway,worker").rsplit(",", 1) + lines = ExtendedString("api\ngateway").splitlines() + joined = ExtendedString(",").join([ExtendedString("api"), "gateway"]) + formatted = ExtendedString("{service}.{component}").format(service="api", component=ExtendedString("worker")) + formatted_map = ExtendedString("{service}.{component}").format_map( + {"service": ExtendedString("api"), "component": "worker"} + ) + decoded_json = ExtendedString('{"service": {"name": "api"}}').decode_json() + decoded_yaml = ExtendedString("service:\n name: api\n").decode_yaml() + decoded_toml = ExtendedString('service = { name = "api" }\n').decode_toml() + decoded_hcl = ExtendedString('locals { service = "api" }\n').decode_hcl2() + encoded_base64 = ExtendedString('{"service": {"name": "api"}}').encode_base64(wrap_raw_data=False) + decoded_base64 = encoded_base64.decode_base64(encoding="json") + plain_decoded_json = ExtendedString('{"service": "api"}').decode_json(as_extended=False) + + assert value.to_snake_case().remove_suffix("_value") == "api_response" + assert value.to_snake_case().remove_prefix("api_") == "response_value" + assert ExtendedString("prefix_value").remove_prefix("prefix_") == "value" + assert ExtendedString("value_suffix").remove_suffix("_suffix") == "value" + assert value.to_kebab_case() == "api-response-value" + assert ExtendedString("1").ordinalize() == "1st" + assert ExtendedString("yes").to_bool() is True + assert ExtendedString("42").to_int() == 42 + assert ExtendedString("3.14").to_float() == 3.14 + assert ExtendedString("/tmp/service.yaml").to_path() == Path("/tmp/service.yaml") + assert ExtendedString("2026-06-10").to_date() == datetime.date(2026, 6, 10) + assert ExtendedString("2026-06-10").reconstruct_special_type() == datetime.date(2026, 6, 10) + assert ExtendedString("echo one\necho two").to_export_safe(export_to_yaml=True) == "echo one\necho two" + assert json.loads(ExtendedString("api").wrap_for_export(allow_encoding="json")) == "api" + reconstructed_json = ExtendedString('{"service": "api"}').reconstruct_special_type() + assert isinstance(reconstructed_json, ExtendedDict) + assert reconstructed_json["service"].upper_first() == "Api" + assert ExtendedString("2026-06-10T12:30:00").to_datetime() == datetime.datetime( + 2026, + 6, + 10, + 12, + 30, + 0, + tzinfo=datetime.timezone.utc, + ) + assert ExtendedString("12:30").to_time() == datetime.time(12, 30) + assert ExtendedString("api-gateway").is_partial_match("gateway") is True + assert ExtendedString("api").is_partial_match("gateway", check_prefix_only=True) is False + assert ExtendedString("API").is_non_empty_match("api") is True + assert ExtendedString("").is_non_empty_match("api") is False + assert isinstance(partitioned, ExtendedTuple) + assert isinstance(partitioned[0], ExtendedString) + assert partitioned == ("api", ".", "gateway.worker") + assert isinstance(right_partitioned, ExtendedTuple) + assert right_partitioned == ("api.gateway", ".", "worker") + assert isinstance(split, ExtendedList) + assert all(isinstance(item, ExtendedString) for item in split) + assert split == ["api", "gateway", "worker"] + assert isinstance(right_split, ExtendedList) + assert right_split == ["api,gateway", "worker"] + assert isinstance(lines, ExtendedList) + assert all(isinstance(item, ExtendedString) for item in lines) + assert lines == ["api", "gateway"] + assert isinstance(joined, ExtendedString) + assert joined == "api,gateway" + assert isinstance(formatted, ExtendedString) + assert formatted == "api.worker" + assert isinstance(formatted_map, ExtendedString) + assert formatted_map == "api.worker" + assert isinstance(decoded_json, ExtendedDict) + assert decoded_json["service"]["name"].upper_first() == "Api" + assert isinstance(decoded_yaml, ExtendedDict) + assert decoded_yaml["service"]["name"].upper_first() == "Api" + assert isinstance(decoded_toml, ExtendedDict) + assert decoded_toml["service"]["name"].upper_first() == "Api" + assert isinstance(decoded_hcl, ExtendedDict) + assert isinstance(decoded_hcl["locals"], ExtendedList) + assert decoded_hcl["locals"][0]["service"].upper_first() == "Api" + assert isinstance(encoded_base64, ExtendedString) + assert isinstance(decoded_base64, ExtendedDict) + assert decoded_base64["service"]["name"].upper_first() == "Api" + assert isinstance(plain_decoded_json, dict) + assert plain_decoded_json == {"service": "api"} + + +def test_extended_dict_composes_mapping_primitives() -> None: + """ExtendedDict composes Tier 1 mapping primitives.""" + value = ExtendedDict({"outer": {"inner": 1}, "items": [1, 1, 2], "empty": ""}) + typed = ExtendedDict({"service": "api", "retries": 2, "enabled": True, "ports": [80, 443]}) + reconstructed = ExtendedDict( + {"enabled": "true", "retries": "5", "service": {"launched": "2026-06-10"}, "ports": ["80"]} + ).reconstruct_special_types() + export_safe = ExtendedDict( + {"launched": datetime.date(2026, 6, 10), "path": Path("/tmp/service.yaml")} + ).to_export_safe() + wrapped_json = ExtendedDict({"service": "api", "retries": 2}).wrap_for_export(allow_encoding="json") + + merged = value.deep_merge({"outer": {"other": 2}}) + filtered = merged.filter(allowlist=["outer"]) + accepted, rejected = filtered + all_values = value.all_values() + split = typed.split_by_type(primitive_only=True) + first_scalar = typed.first_non_empty_value("missing", "service") + first_nested = value.first_non_empty_value("missing", "outer") + first_entry = typed.first_non_empty_entry("missing", "service", "ports") + entries = typed.non_empty_entries("missing", "service", "ports") + + assert isinstance(filtered, ExtendedTuple) + assert isinstance(accepted, ExtendedDict) + assert isinstance(rejected, ExtendedDict) + assert isinstance(all_values, ExtendedList) + assert isinstance(split, ExtendedDict) + assert isinstance(split["str"], ExtendedDict) + assert isinstance(split["list"], ExtendedDict) + assert isinstance(first_scalar, ExtendedString) + assert isinstance(first_nested, ExtendedDict) + assert isinstance(first_entry, ExtendedDict) + assert isinstance(entries, ExtendedList) + assert all(isinstance(entry, ExtendedDict) for entry in entries) + assert isinstance(reconstructed, ExtendedDict) + assert isinstance(reconstructed["service"], ExtendedDict) + assert isinstance(reconstructed["ports"], ExtendedList) + assert export_safe == {"launched": "2026-06-10", "path": "/tmp/service.yaml"} + assert json.loads(wrapped_json) == {"service": "api", "retries": 2} + assert merged["outer"] == {"inner": 1, "other": 2} + assert value["outer"] == {"inner": 1} + assert value.flatten() == {"outer.inner": 1, "items.0": 1, "items.1": 1, "items.2": 2, "empty": ""} + assert value.deduplicate()["items"] == [1, 2] + assert value.compact() == {"outer": {"inner": 1}, "items": [1, 1, 2]} + assert accepted == {"outer": {"inner": 1, "other": 2}} + assert "items" in rejected + assert all_values == [1, 1, 1, 2, ""] + assert isinstance(all_values[-1], ExtendedString) + assert split["str"] == {"service": "api"} + assert split["int"] == {"retries": 2} + assert split["bool"] == {"enabled": True} + assert split["list"] == {"ports": [80, 443]} + assert first_scalar.upper_first() == "Api" + assert first_nested["inner"] == 1 + assert first_entry["service"].upper_first() == "Api" + assert entries == [{"service": "api"}, {"ports": [80, 443]}] + assert isinstance(entries[1]["ports"], ExtendedList) + assert reconstructed["enabled"] is True + assert reconstructed["retries"] == 5 + assert reconstructed["service"]["launched"] == datetime.date(2026, 6, 10) + assert reconstructed["ports"] == [80] + + +def test_extended_dict_promotes_nested_values_on_mutation() -> None: + """ExtendedDict keeps nested values in the Tier 2 surface.""" + value = ExtendedDict({"service": {"name": "api"}}) + + value["owner"] = "platform" + value.update({"ports": [8080, "9090"]}) + value.update([("metadata", {"tier": "prod"})], runtime={"python": "3.13"}) + value.update(other={"literal": "key"}) + defaulted = value.setdefault("labels", {"team": "data"}) + existing = value.setdefault("labels", {"team": "ignored"}) + merged = value | {"deployment": {"region": "us-east-1"}} + right_merged = {"cluster": {"name": "primary"}} | value + value |= {"settings": {"debug": "false"}} + + assert isinstance(value["service"], ExtendedDict) + assert isinstance(value["service"]["name"], ExtendedString) + assert isinstance(value["owner"], ExtendedString) + assert isinstance(value["ports"], ExtendedList) + assert isinstance(value["ports"][1], ExtendedString) + assert isinstance(value["metadata"], ExtendedDict) + assert isinstance(value["metadata"]["tier"], ExtendedString) + assert isinstance(value["runtime"], ExtendedDict) + assert isinstance(value["runtime"]["python"], ExtendedString) + assert isinstance(value["other"], ExtendedDict) + assert isinstance(value["other"]["literal"], ExtendedString) + assert isinstance(defaulted, ExtendedDict) + assert isinstance(defaulted["team"], ExtendedString) + assert existing is defaulted + assert value["labels"]["team"] == "data" + assert isinstance(value["settings"], ExtendedDict) + assert isinstance(value["settings"]["debug"], ExtendedString) + assert isinstance(merged, ExtendedDict) + assert isinstance(merged["deployment"], ExtendedDict) + assert isinstance(merged["deployment"]["region"], ExtendedString) + assert isinstance(right_merged, ExtendedDict) + assert isinstance(right_merged["cluster"], ExtendedDict) + assert isinstance(right_merged["cluster"]["name"], ExtendedString) + assert value["service"]["name"].upper_first() == "Api" + + +def test_extended_dict_update_accepts_keys_getitem_mappings() -> None: + """Mapping-like objects should route through __setitem__ promotion.""" + + class KeyedMapping: + def __init__(self) -> None: + self._data = {"service": {"name": "api"}} + + def keys(self) -> list[str]: + return list(self._data) + + def __getitem__(self, key: str) -> object: + return self._data[key] + + value = ExtendedDict() + + value.update(KeyedMapping()) + + assert isinstance(value["service"], ExtendedDict) + assert isinstance(value["service"]["name"], ExtendedString) + assert value["service"]["name"].upper_first() == "Api" + + +def test_extended_list_composes_sequence_primitives() -> None: + """ExtendedList composes Tier 1 sequence primitives.""" + value = ExtendedList([1, [2, [3]], "", 2]) + typed = ExtendedList(["api", 2, True, ["nested"]]) + first_nested = ExtendedList([None, "", {"service": "api"}]).first_non_empty() + mapped = ExtendedList(["service", "region", "ignored"]).zipmap(["api", "us-east-1"]) + reconstructed = ExtendedList(["true", "5", {"launched": "2026-06-10"}]).reconstruct_special_types() + export_safe = ExtendedList([datetime.date(2026, 6, 10), Path("/tmp/service.yaml")]).to_export_safe() + + assert value.flatten() == [1, 2, 3, "", 2] + assert value.compact() == [1, [2, [3]], 2] + assert value.unique() == [1, [2, [3]], "", 2] + assert isinstance(first_nested, ExtendedDict) + assert first_nested["service"].upper_first() == "Api" + assert isinstance(mapped, ExtendedDict) + assert mapped == {"service": "api", "region": "us-east-1"} + assert mapped["service"].upper_first() == "Api" + assert isinstance(reconstructed, ExtendedList) + assert isinstance(reconstructed[2], ExtendedDict) + assert reconstructed == [True, 5, {"launched": datetime.date(2026, 6, 10)}] + assert export_safe == ["2026-06-10", "/tmp/service.yaml"] + assert value.filter(lambda item: isinstance(item, int)) == [1, 2] + assert ExtendedList([1, 2]).map(lambda item: item * 2) == [2, 4] + assert ExtendedList(["api", "worker", "db"]).filter_values( + allowlist=["api", "worker"], + denylist=["worker"], + ) == ["api"] + split = typed.split_by_type(primitive_only=True) + assert isinstance(split, ExtendedDict) + assert isinstance(split["str"], ExtendedList) + assert isinstance(split["list"], ExtendedList) + assert split["str"] == ["api"] + assert split["int"] == [2] + assert split["bool"] == [True] + assert split["list"] == [["nested"]] + + +def test_extended_list_promotes_nested_values_on_mutation() -> None: + """ExtendedList keeps nested values in the Tier 2 surface.""" + value: ExtendedList[Any] = ExtendedList([{"name": "api"}]) + in_place: ExtendedList[Any] = ExtendedList([{"name": "api"}]) + + value.append("worker") + value.extend([{"name": "scheduler"}]) + value.insert(0, ["frontdoor"]) + value[1] = {"name": "gateway"} + value[2:3] = ["jobs"] + in_place += [{"name": "worker"}, ["jobs"]] + in_place *= 2 + + assert isinstance(value[0], ExtendedList) + assert isinstance(value[0][0], ExtendedString) + assert isinstance(value[1], ExtendedDict) + assert isinstance(value[1]["name"], ExtendedString) + assert isinstance(value[2], ExtendedString) + assert isinstance(value[3], ExtendedDict) + assert value[1]["name"].upper_first() == "Gateway" + assert isinstance(in_place[1], ExtendedDict) + assert isinstance(in_place[1]["name"], ExtendedString) + assert isinstance(in_place[2], ExtendedList) + assert isinstance(in_place[2][0], ExtendedString) + assert isinstance(in_place[4], ExtendedDict) + assert isinstance(in_place[5], ExtendedList) + + +def test_extended_set_composes_set_operations() -> None: + """ExtendedSet provides chainable set operations.""" + value = ExtendedSet({1, 2, 3, None}) + reconstructed = ExtendedSet({"true", "2026-06-10"}).reconstruct_special_types() + export_safe = ExtendedSet({datetime.date(2026, 6, 10)}).to_export_safe() + + compact_repr = repr(value.compact()) + assert compact_repr.startswith("ExtendedSet(") + assert "object at" not in compact_repr + assert isinstance(reconstructed, ExtendedSet) + assert reconstructed.to_set() == {True, datetime.date(2026, 6, 10)} + assert export_safe == ["2026-06-10"] + assert value.compact().to_set() == {1, 2, 3} + assert value.union({4}).to_set() == {1, 2, 3, 4, None} + assert value.intersection({2, 3, 5}).to_set() == {2, 3} + assert value.difference({1, None}).to_set() == {2, 3} + + +def test_extended_set_promotes_string_values() -> None: + """ExtendedSet keeps hashable nested values in the Tier 2 surface.""" + value = ExtendedSet({"api"}) + + value.add("worker") + + assert all(isinstance(item, ExtendedString) for item in value) + assert value.to_set() == {"api", "worker"} + assert to_builtin(value) == {"api", "worker"} + + +def test_extended_set_named_mutators_preserve_extended_values() -> None: + """Named set mutation methods keep values in the Tier 2 surface.""" + value = ExtendedSet({"api"}) + + value.update(["worker"], {"scheduler"}) + symmetric = value.symmetric_difference({"worker", "batch"}) + value.intersection_update({"api", "scheduler", "batch"}) + value.difference_update({"scheduler"}) + value.symmetric_difference_update({"api", "batch"}) + + assert isinstance(symmetric, ExtendedSet) + assert symmetric.to_set() == {"api", "scheduler", "batch"} + assert all(isinstance(item, ExtendedString) for item in symmetric) + assert value.to_set() == {"batch"} + assert all(isinstance(item, ExtendedString) for item in value) + + +def test_extended_tuple_preserves_immutable_sequence_shape() -> None: + """ExtendedTuple composes sequence primitives without becoming an ExtendedList.""" + value = ExtendedTuple((1, (2, [3]), "", 2)) + typed = ExtendedTuple(("api", 2, True, ["nested"])) + first_nested = ExtendedTuple((None, "", {"service": "api"})).first_non_empty() + mapped = ExtendedTuple(("service", "region", "ignored")).zipmap(("api", "us-east-1")) + reconstructed = ExtendedTuple(("true", "5", {"launched": "2026-06-10"})).reconstruct_special_types() + export_safe = ExtendedTuple((datetime.date(2026, 6, 10), Path("/tmp/service.yaml"))).to_export_safe() + split = typed.split_by_type(primitive_only=True) + + assert value.flatten() == (1, 2, 3, "", 2) + assert value.compact() == (1, (2, [3]), 2) + assert value.unique() == (1, (2, [3]), "", 2) + assert isinstance(first_nested, ExtendedDict) + assert first_nested["service"].upper_first() == "Api" + assert isinstance(mapped, ExtendedDict) + assert mapped == {"service": "api", "region": "us-east-1"} + assert mapped["service"].upper_first() == "Api" + assert isinstance(reconstructed, ExtendedTuple) + assert isinstance(reconstructed[2], ExtendedDict) + assert reconstructed == (True, 5, {"launched": datetime.date(2026, 6, 10)}) + assert export_safe == ["2026-06-10", "/tmp/service.yaml"] + assert value.filter(lambda item: isinstance(item, int)) == (1, 2) + assert value.map(lambda item: item * 2 if isinstance(item, int) else item) == (2, (2, [3]), "", 4) + assert isinstance(split, ExtendedDict) + assert isinstance(split["str"], ExtendedTuple) + assert isinstance(split["list"], ExtendedTuple) + assert split["str"] == ("api",) + assert split["int"] == (2,) + assert split["bool"] == (True,) + assert split["list"] == (["nested"],) + + +def test_extended_tuple_promotes_nested_values() -> None: + """ExtendedTuple keeps tuple-shaped values in the Tier 2 surface.""" + value = ExtendedTuple(({"name": "api"}, ["jobs"])) + + assert isinstance(value[0], ExtendedDict) + assert isinstance(value[0]["name"], ExtendedString) + assert isinstance(value[1], ExtendedList) + assert isinstance(value[1][0], ExtendedString) + assert value.to_tuple() == ({"name": "api"}, ["jobs"]) + assert to_builtin(value) == ({"name": "api"}, ["jobs"]) + + +def test_extended_tuple_preserves_surface_for_builtin_tuple_operations() -> None: + """Inherited tuple operations should not leak plain tuple results.""" + value = ExtendedTuple(({"name": "api"}, ["jobs"])) + prefix = ({"name": "gateway"},) + suffix = ({"name": "worker"},) + + sliced = value[:1] + added = value + suffix + right_added = prefix + value + repeated = value * 2 + right_repeated = 2 * value + + assert isinstance(sliced, ExtendedTuple) + assert isinstance(added, ExtendedTuple) + assert isinstance(right_added, ExtendedTuple) + assert isinstance(repeated, ExtendedTuple) + assert isinstance(right_repeated, ExtendedTuple) + assert isinstance(sliced[0], ExtendedDict) + assert isinstance(added[2], ExtendedDict) + assert isinstance(added[2]["name"], ExtendedString) + assert isinstance(right_added[0], ExtendedDict) + assert isinstance(right_added[0]["name"], ExtendedString) + assert isinstance(repeated[2], ExtendedDict) + assert isinstance(right_repeated[2], ExtendedDict) + + +def test_extend_data_recursively_wraps_builtin_containers() -> None: + """The container factory promotes plain values into the Tier 2 surface.""" + wrapped = extend_data( + { + "service": {"name": "api"}, + "ports": [8080, 8081], + "tags": {"prod", "api"}, + "aliases": ("api", "gateway"), + } + ) + + assert isinstance(wrapped, ExtendedDict) + assert isinstance(wrapped["service"], ExtendedDict) + assert isinstance(wrapped["service"]["name"], ExtendedString) + assert isinstance(wrapped["ports"], ExtendedList) + assert isinstance(wrapped["tags"], ExtendedSet) + assert isinstance(wrapped["aliases"], ExtendedTuple) + assert wrapped["service"]["name"].upper_first() == "Api" + + +def test_to_builtin_recursively_unwraps_extended_containers() -> None: + """Extended containers can be lowered back to normal Python data.""" + wrapped = ExtendedDict( + { + "service": ExtendedDict({"name": ExtendedString("api")}), + ExtendedString("metadata"): ExtendedDict({"owner": ExtendedString("platform")}), + "ports": ExtendedList([8080, 8081]), + "tags": ExtendedSet({"prod", "api"}), + "aliases": ExtendedTuple(("api", "gateway")), + } + ) + + plain = to_builtin(wrapped) + + assert isinstance(plain, dict) + assert plain["service"] == {"name": "api"} + metadata_key = next(key for key in plain if key == "metadata") + assert type(metadata_key) is str + assert plain["metadata"] == {"owner": "platform"} + assert plain["ports"] == [8080, 8081] + assert plain["tags"] == {"prod", "api"} + assert plain["aliases"] == ("api", "gateway") + + +def test_container_classes_are_root_exports() -> None: + """Tier 2 containers are root-level convenience exports.""" + assert extended_data.ExtendedString is ExtendedString + assert extended_data.ExtendedDict is ExtendedDict + assert extended_data.ExtendedList is ExtendedList + assert extended_data.ExtendedSet is ExtendedSet + assert extended_data.ExtendedTuple is ExtendedTuple + assert extended_data.extend_data is extend_data + assert extended_data.to_builtin is to_builtin diff --git a/tests/core/test_export_utils.py b/tests/core/test_export_utils.py index 7a5b101..fa25970 100644 --- a/tests/core/test_export_utils.py +++ b/tests/core/test_export_utils.py @@ -21,11 +21,11 @@ import pytest -from extended_data.export_utils import ( +from extended_data.io.exporters import ( make_raw_data_export_safe, wrap_raw_data_for_export, ) -from extended_data.yaml_utils import ( +from extended_data.primitives.formats.yaml import ( LiteralScalarString, YamlPairs, YamlTagged, @@ -156,6 +156,17 @@ def test_wrap_raw_data_for_export_raw_false_and_invalid_values() -> None: wrap_raw_data_for_export(raw_data, allow_encoding="xml") +def test_wrap_raw_data_for_export_redacts_invalid_encoding_value() -> None: + """Invalid export-option diagnostics should not echo secret-bearing values.""" + with pytest.raises(ValueError) as exc_info: + wrap_raw_data_for_export({}, allow_encoding="password=hunter2 Authorization: Bearer raw_token") + + message = str(exc_info.value) + assert "hunter2" not in message + assert "raw_token" not in message + assert "[REDACTED]" in message + + def test_wrap_raw_data_for_export_boolean_string_preserves_yaml_native_data() -> None: """Auto-encoding should keep YAML-native tagged values in YAML form.""" raw_data = {"bucket_name": YamlTagged("!Ref", "BucketName")} diff --git a/tests/core/test_file_data_type.py b/tests/core/test_file_data_type.py index 97d57a3..4dc17cc 100644 --- a/tests/core/test_file_data_type.py +++ b/tests/core/test_file_data_type.py @@ -17,6 +17,7 @@ from __future__ import annotations +from base64 import b64encode from pathlib import Path from typing import Any @@ -24,7 +25,9 @@ from git import GitCommandError, InvalidGitRepositoryError, NoSuchPathError, Repo -from extended_data.file_data_type import ( +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString +from extended_data.io.files import ( + DataFile, FilePath, clone_repository_to_temp, decode_file, @@ -37,6 +40,7 @@ get_tld, is_url, match_file_extensions, + read_data_file, read_file, resolve_local_path, write_file, @@ -80,7 +84,7 @@ def test_get_parent_repository(mocker) -> None: The result of get_parent_repository is either a valid Repo object or None if invalid. """ # Mock the Repo constructor to return a mock Repo instance - mock_repo_constructor = mocker.patch("extended_data.file_data_type.Repo") + mock_repo_constructor = mocker.patch("extended_data.io.files.Repo") mock_repo_instance = mocker.Mock(spec=Repo) mock_repo_constructor.return_value = mock_repo_instance @@ -124,7 +128,8 @@ def test_clone_repository_to_temp(mocker, valid_repo_data: dict) -> None: valid_repo_data: Dictionary containing valid repository data. """ # Mock the Repo.clone_from method to return a mock Repo instance - mock_clone_from = mocker.patch("extended_data.file_data_type.Repo.clone_from") + mock_clone_from = mocker.patch("extended_data.io.files.Repo.clone_from") + mocker.patch.dict("extended_data.io.files.os.environ", {}, clear=True) mock_repo_instance = mocker.Mock(spec=Repo) mock_clone_from.return_value = mock_repo_instance @@ -134,6 +139,15 @@ def test_clone_repository_to_temp(mocker, valid_repo_data: dict) -> None: # Assert that temp_dir is a Path instance and repo is the mocked Repo instance assert isinstance(temp_dir, Path) assert repo is mock_repo_instance + clone_url = mock_clone_from.call_args.args[0] + clone_env = mock_clone_from.call_args.kwargs["env"] + expected_header = b64encode(b"x-access-token:token123").decode("ascii") + + assert clone_url == "https://github.com/owner/repo.git" + assert "token123" not in clone_url + assert clone_env["GIT_CONFIG_KEY_0"] == "http.https://github.com/.extraheader" + assert clone_env["GIT_CONFIG_VALUE_0"] == f"Authorization: Basic {expected_header}" + assert clone_env["GIT_CONFIG_COUNT"] == "1" # Test cloning with errors mock_clone_from.side_effect = GitCommandError("Error", "git") @@ -156,7 +170,7 @@ def test_clone_repository_to_temp_additional_errors( mocker, valid_repo_data: dict, side_effect: Exception, message: str ) -> None: """Map additional git clone failures to consistent OSError messages.""" - mocker.patch("extended_data.file_data_type.Repo.clone_from", side_effect=side_effect) + mocker.patch("extended_data.io.files.Repo.clone_from", side_effect=side_effect) with pytest.raises(OSError, match=message): clone_repository_to_temp(**valid_repo_data) @@ -171,7 +185,7 @@ def test_get_tld(mocker) -> None: The result of get_tld matches the expected top-level directory or None if not a repository. """ # Mock get_parent_repository to return a mock Repo instance - mock_get_parent_repo = mocker.patch("extended_data.file_data_type.get_parent_repository") + mock_get_parent_repo = mocker.patch("extended_data.io.files.get_parent_repository") mock_repo_instance = mocker.Mock(spec=Repo) mock_repo_instance.working_tree_dir = "/valid/repo" mock_get_parent_repo.return_value = mock_repo_instance @@ -341,7 +355,7 @@ def test_resolve_local_path_relative_no_tld(mocker) -> None: Asserts: RuntimeError is raised when no tld is available. """ - mocker.patch("extended_data.file_data_type.get_tld", return_value=None) + mocker.patch("extended_data.io.files.get_tld", return_value=None) with pytest.raises(RuntimeError, match="Cannot resolve relative path"): resolve_local_path("relative/file.txt") @@ -392,7 +406,7 @@ def read(self) -> bytes: return b"hello from url" mock_urlopen = mocker.patch( - "extended_data.file_data_type.urllib.request.urlopen", + "extended_data.io.files.urllib.request.urlopen", return_value=MockResponse(), ) @@ -418,7 +432,7 @@ def read(self) -> bytes: return b"\x00\x01\x02" mocker.patch( - "extended_data.file_data_type.urllib.request.urlopen", + "extended_data.io.files.urllib.request.urlopen", return_value=MockResponse(), ) @@ -456,10 +470,10 @@ def test_read_file_return_path(tmp_path: Path) -> None: @pytest.mark.parametrize( ("data", "suffix", "expected_type"), [ - ('{"key": "value"}', "json", dict), - ("key: value", "yaml", dict), - ("key: value", "yml", dict), - ("plain text", "txt", str), + ('{"key": "value"}', "json", ExtendedDict), + ("key: value", "yaml", ExtendedDict), + ("key: value", "yml", ExtendedDict), + ("plain text", "txt", ExtendedString), ], ) def test_decode_file(data: str, suffix: str, expected_type: type) -> None: @@ -477,6 +491,14 @@ def test_decode_file(data: str, suffix: str, expected_type: type) -> None: assert isinstance(result, expected_type) +def test_decode_file_can_return_builtin_containers() -> None: + """File decoding can explicitly lower back to plain Python containers.""" + result = decode_file('{"key": "value"}', suffix="json", as_extended=False) + + assert isinstance(result, dict) + assert not isinstance(result, ExtendedDict) + + def test_decode_file_infer_suffix() -> None: """Tests decode_file inferring suffix from file path. @@ -484,28 +506,157 @@ def test_decode_file_infer_suffix() -> None: Suffix is correctly inferred from file path. """ result = decode_file('{"key": "value"}', file_path="/path/to/file.json") - assert isinstance(result, dict) + assert isinstance(result, ExtendedDict) assert result == {"key": "value"} def test_decode_file_infer_hcl_suffix() -> None: """Infer HCL decoding from a Terraform file path.""" result = decode_file('variable "region" { default = "us-east-1" }', file_path="/path/to/variables.tf") + assert isinstance(result, ExtendedDict) assert result == {"variable": [{"region": {"default": "us-east-1"}}]} def test_decode_file_infer_toml_alias_suffix() -> None: """Infer TOML decoding from historical .tml suffixes.""" result = decode_file('title = "Example"\n', file_path="/path/to/config.tml") + assert isinstance(result, ExtendedDict) assert result == {"title": "Example"} def test_decode_file_accepts_bytes_payload() -> None: """Decode bytes-like payloads through the same file helper.""" result = decode_file(b'{"key":"value"}', file_path="/path/to/file.json") + assert isinstance(result, ExtendedDict) assert result == {"key": "value"} +def test_decode_file_returns_extended_containers_by_default() -> None: + """File decoding enters the Tier 2 container layer by default.""" + result = decode_file( + '{"service": {"name": "api"}, "ports": [8080]}', + file_path="/path/to/file.json", + ) + + assert isinstance(result, ExtendedDict) + assert isinstance(result["service"], ExtendedDict) + assert isinstance(result["service"]["name"], ExtendedString) + assert isinstance(result["ports"], ExtendedList) + + +def test_read_data_file_reads_and_decodes_extended_data(tmp_path: Path) -> None: + """Data-file reads enter the Tier 2 container layer in one operation.""" + test_file = tmp_path / "service.json" + test_file.write_text('{"service": {"name": "api"}, "ports": [8080]}') + + result = read_data_file(test_file, tld=tmp_path) + + assert isinstance(result, ExtendedDict) + assert isinstance(result["service"], ExtendedDict) + assert isinstance(result["service"]["name"], ExtendedString) + assert isinstance(result["ports"], ExtendedList) + assert result["service"]["name"].upper_first() == "Api" + + +def test_read_data_file_can_return_builtin_data(tmp_path: Path) -> None: + """The composed file-data boundary can explicitly return plain Python values.""" + test_file = tmp_path / "service.json" + test_file.write_text('{"service": {"name": "api"}}') + + result = read_data_file(test_file, as_extended=False, tld=tmp_path) + + assert isinstance(result, dict) + assert not isinstance(result, ExtendedDict) + assert isinstance(result["service"], dict) + + +def test_read_data_file_raises_for_missing_file(tmp_path: Path) -> None: + """Missing data-file reads fail loudly.""" + with pytest.raises(FileNotFoundError, match=r"missing\.json"): + read_data_file("missing.json", tld=tmp_path) + + +def test_data_file_read_promotes_data_and_metadata(tmp_path: Path) -> None: + """DataFile reads keep file data and source metadata in the promoted surface.""" + test_file = tmp_path / "service.json" + test_file.write_text('{"service": {"name": "api"}, "ports": [8080]}') + + artifact = DataFile.read("service.json", tld=tmp_path) + + assert artifact.source == "service.json" + assert artifact.encoding == "json" + assert artifact.path == test_file.resolve() + assert isinstance(artifact.data, ExtendedDict) + assert isinstance(artifact.data["service"], ExtendedDict) + assert isinstance(artifact.data["service"]["name"], ExtendedString) + assert isinstance(artifact.metadata, ExtendedDict) + assert artifact.metadata["encoding"].upper_first() == "Json" + assert artifact.metadata["path"] == str(test_file.resolve()) + assert artifact.metadata["is_url"] is False + assert artifact.as_builtin() == {"service": {"name": "api"}, "ports": [8080]} + + +def test_data_file_extended_view_is_detached() -> None: + """DataFile promoted views should not share mutable state with artifact data.""" + artifact = DataFile.decode('{"service": {"name": "api"}}', suffix="json") + + promoted = artifact.as_extended() + promoted["service"]["name"] = "worker" + + assert isinstance(promoted, ExtendedDict) + assert artifact.data["service"]["name"] == "api" + assert artifact.as_extended()["service"]["name"].upper_first() == "Api" + + +def test_data_file_decode_and_write_round_trip(tmp_path: Path) -> None: + """DataFile composes decode, export, write, and readback as a Tier 3 artifact.""" + artifact = DataFile.decode('{"service": {"name": "api"}}', suffix="json") + + assert isinstance(artifact.data, ExtendedDict) + assert artifact.source == "memory" + assert artifact.encoding == "json" + assert artifact.wrap_for_export(allow_encoding="json").strip().startswith("{") + + output = artifact.write("build/service.yaml", tld=tmp_path) + + assert output.path == tmp_path / "build" / "service.yaml" + assert output.encoding == "yaml" + assert isinstance(output.metadata["source"], ExtendedString) + assert read_data_file(output.path) == {"service": {"name": "api"}} + + +def test_data_file_redacts_secret_bearing_source_and_metadata() -> None: + """DataFile provenance should be safe to carry into workflow metadata.""" + artifact = DataFile.decode( + '{"service": {"name": "api"}}', + file_path="https://example.com/config.json?api_key=key_123®ion=us-east-1", + suffix="json", + metadata={ + "authorization": "Bearer raw_token", + "nested": {"client_secret": "secret_456"}, + "source": "password=hunter2", + }, + ) + workflow = artifact.workflow() + + assert "key_123" not in artifact.source + assert "region=us-east-1" in artifact.source + assert artifact.metadata["source"] == artifact.source + assert artifact.metadata["authorization"] == "[REDACTED]" + assert artifact.metadata["nested"]["client_secret"] == "[REDACTED]" + assert "hunter2" not in artifact.metadata["source"] + assert workflow.metadata["source"] == artifact.source + assert "key_123" not in workflow.steps[0] + + +def test_data_file_write_without_local_target_fails_loudly() -> None: + """In-memory DataFile artifacts require an explicit output path.""" + artifact = DataFile.decode("plain text", suffix="raw") + + with pytest.raises(ValueError, match="pass file_path"): + artifact.write() + + def test_write_file_json(tmp_path: Path) -> None: """Tests writing data as JSON. diff --git a/tests/core/test_hcl2_utils.py b/tests/core/test_hcl2_utils.py index 2357a01..853d049 100644 --- a/tests/core/test_hcl2_utils.py +++ b/tests/core/test_hcl2_utils.py @@ -4,10 +4,10 @@ import pytest -from lark.exceptions import ParseError, UnexpectedToken - -from extended_data import hcl2_utils -from extended_data.hcl2_utils import decode_hcl2, encode_hcl2 +from extended_data.containers import ExtendedDict +from extended_data.primitives.formats import hcl as hcl2_utils +from extended_data.primitives.formats.errors import DataDecodeError +from extended_data.primitives.formats.hcl import decode_hcl2, encode_hcl2 @pytest.fixture @@ -56,13 +56,18 @@ def test_decode_hcl2_empty() -> None: def test_decode_hcl2_invalid() -> None: """Reject invalid HCL input.""" - with pytest.raises(UnexpectedToken): - decode_hcl2("invalid hcl2 data") + with pytest.raises(DataDecodeError) as exc_info: + decode_hcl2('locals { token = "super-secret" ') + + message = str(exc_info.value) + assert "Failed to decode HCL2 data" in message + assert "line 1" in message + assert "super-secret" not in message def test_decode_hcl2_invalid_bytes() -> None: """Reject byte input that cannot be decoded as UTF-8.""" - with pytest.raises(ParseError, match="Failed to decode bytes to string"): + with pytest.raises(DataDecodeError, match="input bytes are not valid UTF-8"): decode_hcl2(b"\x80") @@ -291,3 +296,15 @@ def test_encode_hcl2_rejects_non_mapping_root() -> None: """Reject document roots that are not HCL bodies.""" with pytest.raises(TypeError, match="mapping at the document root"): encode_hcl2(["not", "a", "mapping"]) + + +@pytest.mark.parametrize("use_data_attribute", [False, True]) +def test_encode_hcl2_lowers_extended_containers(use_data_attribute: bool) -> None: + """Encode Tier 2 containers before validating and rendering HCL.""" + payload = ExtendedDict({"locals": [{"service_name": "api", "ports": [80, 443]}]}) + raw_data = payload.data if use_data_attribute else payload + + encoded = encode_hcl2(raw_data) + + assert 'service_name = "api"' in encoded + assert decode_hcl2(encoded) == {"locals": [{"service_name": "api", "ports": [80, 443]}]} diff --git a/tests/core/test_import_utils.py b/tests/core/test_import_utils.py index f26237f..8ddab89 100644 --- a/tests/core/test_import_utils.py +++ b/tests/core/test_import_utils.py @@ -4,7 +4,8 @@ import pytest -from extended_data.import_utils import unwrap_raw_data_from_import +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedString +from extended_data.io.importers import unwrap_raw_data_from_import @pytest.mark.parametrize( @@ -35,3 +36,32 @@ def test_unwrap_raw_data_from_import_rejects_unsupported_encoding() -> None: """Reject unsupported import encodings.""" with pytest.raises(ValueError, match="Unsupported encoding format: xml"): unwrap_raw_data_from_import("value", "xml") + + +def test_unwrap_raw_data_from_import_returns_extended_containers_by_default() -> None: + """Decoded imports enter the Tier 2 container layer by default.""" + result = unwrap_raw_data_from_import( + '{"service": {"name": "api"}, "ports": [8080]}', + encoding="json", + ) + + assert isinstance(result, ExtendedDict) + assert isinstance(result["service"], ExtendedDict) + assert isinstance(result["service"]["name"], ExtendedString) + assert isinstance(result["ports"], ExtendedList) + + +def test_unwrap_raw_data_from_import_can_return_builtin_containers() -> None: + """Decoded imports can explicitly return plain Python containers.""" + result = unwrap_raw_data_from_import('{"service": {"name": "api"}}', encoding="json", as_extended=False) + + assert isinstance(result, dict) + assert not isinstance(result, ExtendedDict) + + +def test_unwrap_raw_data_from_import_returns_extended_raw_strings_by_default() -> None: + """Raw imports promote to ExtendedString by default.""" + result = unwrap_raw_data_from_import("plain text", encoding="raw") + + assert isinstance(result, ExtendedString) + assert result.upper_first() == "Plain text" diff --git a/tests/core/test_integration_workflows.py b/tests/core/test_integration_workflows.py index 04f0aed..4d5466c 100644 --- a/tests/core/test_integration_workflows.py +++ b/tests/core/test_integration_workflows.py @@ -26,25 +26,26 @@ def test_integration_workflow_serialization_transformation_export(): } edt.write_file(tmp_path, raw_data) - # 2. Read and Decode - content = edt.read_file(tmp_path) - loaded_data = edt.decode_file(content, file_path=tmp_path) + # 2. Read and decode through the Tier 3 file boundary + loaded_data = edt.read_data_file(tmp_path) assert loaded_data == raw_data + assert isinstance(loaded_data, edt.ExtendedDict) # 3. Transform: Convert types and transform strings transformed = { - "name": edt.to_pascal_case(loaded_data["project_name"]), - "config": edt.reconstruct_special_types(loaded_data["settings"]), - "item_list": [edt.humanize(i) for i in loaded_data["items"]], + "name": loaded_data["project_name"].to_pascal_case(), + "config": loaded_data["settings"].reconstruct_special_types(), + "item_list": [item.humanize() for item in loaded_data["items"]], } assert transformed["name"] == "MyGreatProject" + assert isinstance(transformed["config"], edt.ExtendedDict) assert transformed["config"]["enable_feature"] is True assert transformed["config"]["max_retries"] == 5 assert transformed["item_list"] == ["Item one", "Item two"] # 4. Export: Make safe for export (e.g. GitHub Actions) - export_safe = edt.make_raw_data_export_safe(transformed) + export_safe = edt.ExtendedDict(transformed).to_export_safe() assert isinstance(export_safe, dict) # Verify it's still equivalent assert export_safe["name"] == "MyGreatProject" @@ -74,15 +75,15 @@ def test_integration_workflow_data_transformation_pipeline(): dict1 = {"a": {"b": 1}, "c": 3} dict2 = {"a": {"d": 4}, "e": 5} - # 1. Merge maps - merged = edt.deep_merge(dict1, dict2) + # 1. Merge maps through the Tier 2 container surface + merged = edt.ExtendedDict(dict1).deep_merge(dict2) assert merged["a"] == {"b": 1, "d": 4} # 2. Flatten map - flattened = edt.flatten_map(merged) + flattened = merged.flatten() assert flattened["a.b"] == 1 assert flattened["a.d"] == 4 # 3. Transform keys - unhumped = edt.unhump_map(merged) + unhumped = merged.unhump() assert "a" in unhumped diff --git a/tests/core/test_json_utils.py b/tests/core/test_json_utils.py index 0184c24..4b5546d 100644 --- a/tests/core/test_json_utils.py +++ b/tests/core/test_json_utils.py @@ -15,7 +15,9 @@ import pytest -from extended_data.json_utils import decode_json, encode_json +from extended_data.containers import ExtendedDict, ExtendedString +from extended_data.primitives.formats.errors import DataDecodeError +from extended_data.primitives.formats.json import decode_json, encode_json @pytest.fixture @@ -67,6 +69,17 @@ def test_decode_json(simple_json: str, simple_dict: dict) -> None: assert result == simple_dict +def test_decode_json_invalid_input_raises_sanitized_decode_error() -> None: + """Invalid JSON raises a package-owned decode error without echoing values.""" + with pytest.raises(DataDecodeError) as exc_info: + decode_json('{"token": "super-secret"') + + message = str(exc_info.value) + assert "Failed to decode JSON data" in message + assert "line 1" in message + assert "super-secret" not in message + + def test_encode_json(simple_dict: dict, simple_json: str) -> None: """Tests encoding of a dictionary to JSON format. @@ -99,3 +112,23 @@ def test_encode_json_bytes_output(simple_dict: dict) -> None: """ result = encode_json(simple_dict) assert isinstance(result, str) + + +@pytest.mark.parametrize("use_data_attribute", [False, True]) +def test_encode_json_lowers_extended_containers(use_data_attribute: bool) -> None: + """Encode Tier 2 containers as their plain JSON-compatible contents.""" + payload = ExtendedDict({"status": "ok", "items": ["one"]}) + raw_data = payload.data if use_data_attribute else payload + + result = encode_json(raw_data, sort_keys=True) + + assert decode_json(result) == {"items": ["one"], "status": "ok"} + + +def test_encode_json_lowers_extended_mapping_keys() -> None: + """Extended mapping keys are lowered before JSON handoff.""" + payload = ExtendedDict({ExtendedString("service"): {"name": "api"}}) + + result = encode_json(payload, sort_keys=True) + + assert decode_json(result) == {"service": {"name": "api"}} diff --git a/tests/core/test_list_data_type.py b/tests/core/test_list_data_type.py index f0e1733..d81e254 100644 --- a/tests/core/test_list_data_type.py +++ b/tests/core/test_list_data_type.py @@ -22,7 +22,7 @@ import pytest -from extended_data.list_data_type import filter_list, flatten_list +from extended_data.primitives.sequences import filter_list, flatten_list @pytest.fixture @@ -121,6 +121,22 @@ def test_filter_list_empty_allowlist_behaves_like_no_filter( assert result == test_list +def test_filter_list_handles_non_string_values() -> None: + """Filtering is generic across value types.""" + result = filter_list([1, 2, 3, 4], allowlist={1, 2, 4}, denylist={4}) + assert result == [1, 2] + + +def test_filter_list_handles_unhashable_values() -> None: + """Filtering should not require set-compatible values.""" + api = {"name": "api"} + worker = {"name": "worker"} + db = {"name": "db"} + + result = filter_list([api, worker, db], allowlist=[api, worker], denylist=[worker]) + assert result == [api] + + def test_filter_list_denylist(test_list: list[str], denylist: list[str]) -> None: """Tests filtering a list with a denylist. diff --git a/tests/core/test_map_data_type.py b/tests/core/test_map_data_type.py index 2510984..d5068b6 100644 --- a/tests/core/test_map_data_type.py +++ b/tests/core/test_map_data_type.py @@ -37,7 +37,7 @@ import pytest -from extended_data.map_data_type import ( +from extended_data.primitives.mappings import ( SortedDefaultDict, all_values_from_map, create_merger, @@ -226,10 +226,12 @@ def test_first_non_empty_value_from_map_returns_none_for_falsy_values() -> None: def test_deep_merge_merges_nested_dicts_lists_and_sets() -> None: """Merge nested structures using the default strategies.""" + first = {"config": {"enabled": True}, "items": [1], "tags": {"a"}} + second = {"config": {"threshold": 2}, "items": [2], "tags": {"b"}} result = deep_merge( {}, - {"config": {"enabled": True}, "items": [1], "tags": {"a"}}, - {"config": {"threshold": 2}, "items": [2], "tags": {"b"}}, + first, + second, ) assert result == { @@ -237,6 +239,8 @@ def test_deep_merge_merges_nested_dicts_lists_and_sets() -> None: "items": [1, 2], "tags": {"a", "b"}, } + assert first == {"config": {"enabled": True}, "items": [1], "tags": {"a"}} + assert second == {"config": {"threshold": 2}, "items": [2], "tags": {"b"}} def test_create_merger_can_override_list_values() -> None: diff --git a/tests/core/test_matcher_utils.py b/tests/core/test_matcher_utils.py index 7fbdcce..f7e7942 100644 --- a/tests/core/test_matcher_utils.py +++ b/tests/core/test_matcher_utils.py @@ -12,7 +12,7 @@ import pytest -from extended_data.matcher_utils import is_non_empty_match, is_partial_match +from extended_data.primitives.matching import is_non_empty_match, is_partial_match @pytest.mark.parametrize( diff --git a/tests/core/test_number_transformations.py b/tests/core/test_number_transformations.py index b00a296..a8000ba 100644 --- a/tests/core/test_number_transformations.py +++ b/tests/core/test_number_transformations.py @@ -4,7 +4,7 @@ import pytest -from extended_data.number_transformations import ( +from extended_data.primitives.numbers import ( from_roman, number_to_currency, number_to_ordinal, diff --git a/tests/core/test_package_cli.py b/tests/core/test_package_cli.py new file mode 100644 index 0000000..baacbd5 --- /dev/null +++ b/tests/core/test_package_cli.py @@ -0,0 +1,266 @@ +"""Tests for the top-level Extended Data CLI.""" + +from __future__ import annotations + +import json + +from unittest.mock import patch + +from extended_data import cli as cli_module + + +def _stdout_text(mock_write) -> str: + """Return concatenated stdout writes from a patched writer.""" + return "".join(call.args[0] for call in mock_write.call_args_list if call.args) + + +def test_decode_inline_json_exports_through_datafile_boundary() -> None: + """The top-level CLI should expose Tier 3 decode/export utilities.""" + with patch("sys.stdout.write") as mock_write: + exit_code = cli_module.main(["decode", '{"service": {"name": "api"}}', "--suffix", "json"]) + + assert exit_code == 0 + assert json.loads(_stdout_text(mock_write)) == {"service": {"name": "api"}} + + +def test_decode_file_can_export_yaml(tmp_path) -> None: + """File decoding should use DataFile and the shared export boundary.""" + config = tmp_path / "service.json" + config.write_text('{"service": {"name": "api"}}', encoding="utf-8") + + with patch("sys.stdout.write") as mock_write: + exit_code = cli_module.main(["decode", "--file", str(config), "--output", "yaml"]) + + assert exit_code == 0 + output = _stdout_text(mock_write) + assert "service:" in output + assert "name: api" in output + + +def test_decode_requires_one_input_source() -> None: + """Decode should fail clearly when no inline value or file path is supplied.""" + with patch("sys.stderr.write") as mock_write: + exit_code = cli_module.main(["decode"]) + + assert exit_code == 1 + assert "pass VALUE or --file" in _stdout_text(mock_write) + + +def test_decode_rejects_ambiguous_input_sources(tmp_path) -> None: + """Decode should not guess when both inline and file input are supplied.""" + config = tmp_path / "service.json" + config.write_text('{"service": "api"}', encoding="utf-8") + + with patch("sys.stderr.write") as mock_write: + exit_code = cli_module.main(["decode", "{}", "--file", str(config)]) + + assert exit_code == 1 + assert "pass either VALUE or --file" in _stdout_text(mock_write) + + +def test_inspect_file_exports_datafile_metadata(tmp_path) -> None: + """Inspect should expose the same promoted metadata DataFile carries.""" + config = tmp_path / "service.yaml" + config.write_text("service:\n name: api\n", encoding="utf-8") + + with patch("sys.stdout.write") as mock_write: + exit_code = cli_module.main(["inspect", "--file", str(config)]) + + assert exit_code == 0 + metadata = json.loads(_stdout_text(mock_write)) + assert metadata["source"] == str(config) + assert metadata["encoding"] == "yaml" + assert metadata["path"] == str(config.resolve()) + assert metadata["is_url"] is False + assert metadata["data_type"] == "ExtendedDict" + + +def test_inspect_inline_payload_reports_memory_source() -> None: + """Inline inspect keeps in-memory payload provenance explicit.""" + with patch("sys.stdout.write") as mock_write: + exit_code = cli_module.main(["inspect", '{"service": "api"}', "--suffix", "json"]) + + assert exit_code == 0 + metadata = json.loads(_stdout_text(mock_write)) + assert metadata["source"] == "memory" + assert metadata["encoding"] == "json" + assert metadata["path"] is None + assert metadata["data_type"] == "ExtendedDict" + + +def test_inspect_rejects_ambiguous_input_sources(tmp_path) -> None: + """Inspect should share decode's explicit source selection behavior.""" + config = tmp_path / "service.json" + config.write_text('{"service": "api"}', encoding="utf-8") + + with patch("sys.stderr.write") as mock_write: + exit_code = cli_module.main(["inspect", "{}", "--file", str(config)]) + + assert exit_code == 1 + assert "pass either VALUE or --file" in _stdout_text(mock_write) + + +def test_connector_commands_delegate_to_connector_cli() -> None: + """Existing connector commands remain available from the package entrypoint.""" + with patch("extended_data.connectors.cli.main", return_value=7) as mock_main: + exit_code = cli_module.main(["list", "--json"]) + + assert exit_code == 7 + mock_main.assert_called_once_with(["list", "--json"]) + + +def test_merge_files_exports_deep_merged_workflow_result(tmp_path) -> None: + """The top-level CLI should expose a DataWorkflow-backed merge command.""" + base = tmp_path / "base.yaml" + env = tmp_path / "env.yaml" + base.write_text("service:\n name: api\n debug: false\nports:\n - 8080\n", encoding="utf-8") + env.write_text("service:\n debug: true\nports:\n - 8081\n", encoding="utf-8") + + with patch("sys.stdout.write") as mock_write: + exit_code = cli_module.main(["merge", str(base), str(env), "--output", "json"]) + + assert exit_code == 0 + assert json.loads(_stdout_text(mock_write)) == { + "service": {"name": "api", "debug": True}, + "ports": [8080, 8081], + } + + +def test_merge_files_can_write_output_artifact(tmp_path) -> None: + """Merged workflow output can be written through the shared file boundary.""" + base = tmp_path / "base.json" + env = tmp_path / "env.json" + output = tmp_path / "build" / "service.yaml" + base.write_text('{"service": {"name": "api", "debug": false}}', encoding="utf-8") + env.write_text('{"service": {"debug": true}}', encoding="utf-8") + + with patch("sys.stdout.write") as mock_write: + exit_code = cli_module.main(["merge", str(base), str(env), "--output", "yaml", "--write", str(output)]) + + assert exit_code == 0 + output_text = output.read_text(encoding="utf-8") + assert _stdout_text(mock_write) == f"{output_text}\n" + assert "debug: true" in output_text + + +def test_merge_requires_multiple_files(tmp_path) -> None: + """Merge should fail loudly instead of treating a single file as a workflow.""" + base = tmp_path / "base.json" + base.write_text('{"service": "api"}', encoding="utf-8") + + with patch("sys.stderr.write") as mock_write: + exit_code = cli_module.main(["merge", str(base)]) + + assert exit_code == 1 + assert "merge requires at least two files" in _stdout_text(mock_write) + + +def test_transform_file_applies_ordered_tier2_steps(tmp_path) -> None: + """Transform should expose common Tier 2 operations through DataWorkflow.""" + payload = tmp_path / "payload.json" + payload.write_text( + '{"HTTPResponseCode": "200", "SelectedServices": ["api", "api", "worker"], "EmptyValue": ""}', + encoding="utf-8", + ) + + with patch("sys.stdout.write") as mock_write: + exit_code = cli_module.main( + [ + "transform", + "--file", + str(payload), + "--step", + "reconstruct", + "--step", + "unhump", + "--step", + "deduplicate", + "--step", + "compact", + ] + ) + + assert exit_code == 0 + assert json.loads(_stdout_text(mock_write)) == { + "http_response_code": 200, + "selected_services": ["api", "worker"], + } + + +def test_transform_inline_string_applies_string_primitives() -> None: + """String-specific transforms should be available from the package CLI.""" + with patch("sys.stdout.write") as mock_write: + exit_code = cli_module.main( + [ + "transform", + "API Response Value", + "--suffix", + "raw", + "--step", + "to-snake-case", + "--output", + "raw", + ] + ) + + assert exit_code == 0 + assert _stdout_text(mock_write) == "api_response_value\n" + + +def test_transform_inline_string_can_reconstruct_scalar() -> None: + """Scalar reconstruction should use the string primitive when needed.""" + with patch("sys.stdout.write") as mock_write: + exit_code = cli_module.main(["transform", "200", "--suffix", "raw", "--step", "reconstruct"]) + + assert exit_code == 0 + assert json.loads(_stdout_text(mock_write)) == 200 + + +def test_transform_requires_at_least_one_step(tmp_path) -> None: + """Transform should fail clearly when no primitive/container step is requested.""" + payload = tmp_path / "payload.json" + payload.write_text('{"service": "api"}', encoding="utf-8") + + with patch("sys.stderr.write") as mock_write: + exit_code = cli_module.main(["transform", "--file", str(payload)]) + + assert exit_code == 1 + assert "transform requires at least one --step" in _stdout_text(mock_write) + + +def test_transform_reports_step_that_does_not_match_data_shape() -> None: + """Shape-specific transforms should fail loudly instead of silently lowering data.""" + with patch("sys.stderr.write") as mock_write: + exit_code = cli_module.main(["transform", '["api"]', "--suffix", "json", "--step", "unhump"]) + + assert exit_code == 1 + assert "transform 'unhump' is not available for ExtendedList" in _stdout_text(mock_write) + + +def test_transform_can_write_output_artifact(tmp_path) -> None: + """Transformed workflow output can be written through the shared file boundary.""" + payload = tmp_path / "payload.json" + output = tmp_path / "build" / "payload.yaml" + payload.write_text('{"HTTPResponseCode": "200"}', encoding="utf-8") + + with patch("sys.stdout.write") as mock_write: + exit_code = cli_module.main( + [ + "transform", + "--file", + str(payload), + "--step", + "reconstruct", + "--step", + "unhump", + "--output", + "yaml", + "--write", + str(output), + ] + ) + + assert exit_code == 0 + output_text = output.read_text(encoding="utf-8") + assert _stdout_text(mock_write) == f"{output_text}\n" + assert "http_response_code: 200" in output_text diff --git a/tests/core/test_package_surface.py b/tests/core/test_package_surface.py index 8405e22..d029434 100644 --- a/tests/core/test_package_surface.py +++ b/tests/core/test_package_surface.py @@ -2,17 +2,46 @@ from __future__ import annotations +from importlib import util from importlib.metadata import version +from types import ModuleType +from typing import get_type_hints import extended_data import extended_data.logging as lifecycle_logging -from extended_data import connectors, inputs +from extended_data import connectors, containers, inputs, io, primitives, secrets, workflows from extended_data.connectors.connectors import ConnectorFabric +from extended_data.connectors.registry import BUILTIN_CONNECTORS +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedSet, ExtendedString, ExtendedTuple from extended_data.inputs import InputProvider from extended_data.logging import Logging +PUBLIC_MODULES = ( + extended_data, + primitives, + containers, + io, + inputs, + lifecycle_logging, + connectors, + secrets, + workflows, +) + + +def _assert_public_exports_resolve(module: ModuleType) -> None: + exports = module.__all__ + + assert len(exports) == len(set(exports)), f"{module.__name__}.__all__ contains duplicates" + + for name in exports: + value = getattr(module, name) + + assert value is not None, f"{module.__name__}.{name} exported None" + + def test_package_version_is_distribution_version() -> None: """All integrated package namespaces expose the distribution version.""" expected = version("extended-data") @@ -21,20 +50,276 @@ def test_package_version_is_distribution_version() -> None: assert connectors.__version__ == expected assert inputs.__version__ == expected assert lifecycle_logging.__version__ == expected + assert secrets.__version__ == expected + + +def test_public_all_exports_resolve_to_real_values() -> None: + """Public package modules should not advertise missing or sentinel exports.""" + for module in PUBLIC_MODULES: + _assert_public_exports_resolve(module) + + +def test_public_all_exports_are_import_star_visible() -> None: + """Star imports should expose exactly the advertised public names.""" + for module in PUBLIC_MODULES: + namespace: dict[str, object] = {} + exec(f"from {module.__name__} import *", {}, namespace) + namespace.pop("__builtins__", None) + + assert set(namespace) == set(module.__all__) + + +def test_root_exports_tiered_data_surfaces() -> None: + """The root package should expose integrated container, IO, and workflow surfaces.""" + for module in (containers, io, workflows): + assert set(module.__all__) <= set(extended_data.__all__), module.__name__ + + +def test_tier1_primitives_are_not_root_exports() -> None: + """Tier 1 functions and codecs should be imported from extended_data.primitives.""" + for name in primitives.__all__: + assert hasattr(primitives, name), name + assert not hasattr(extended_data, name), name + assert name not in extended_data.__all__ + + +def test_root_lazy_exports_do_not_describe_tier1_primitives() -> None: + """The package root lazy loader should not present Tier 1 primitives as root exports.""" + lazy_loader_docs = extended_data.__getattr__.__doc__ or "" + + assert "primitives" not in lazy_loader_docs + assert "connectors and processors" in lazy_loader_docs def test_clean_major_version_public_names() -> None: """The public surface uses integrated extended-data names.""" assert inputs.InputProvider.__name__ == "InputProvider" + assert connectors.ConnectorBase.__name__ == "ConnectorBase" assert connectors.ConnectorFabric is ConnectorFabric + assert extended_data.ConnectorBase is connectors.ConnectorBase assert not hasattr(inputs, "DirectedInputsClass") + assert not hasattr(connectors, "VendorConnectorBase") assert not hasattr(connectors, "VendorConnectors") + assert not hasattr(extended_data, "VendorConnectorBase") + assert not hasattr(connectors, "AWSConnectorFull") + assert not hasattr(connectors, "GoogleConnectorFull") + assert not hasattr(connectors, "GoogleCloudConnector") + assert not hasattr(connectors, "GoogleWorkspaceConnector") + assert not hasattr(connectors, "GoogleBillingConnector") + assert not hasattr(extended_data, "GoogleCloudConnector") + assert not hasattr(extended_data, "GoogleWorkspaceConnector") + assert not hasattr(extended_data, "GoogleBillingConnector") + assert not hasattr(primitives, "SortedDefaultDict") + assert not hasattr(extended_data, "SortedDefaultDict") + assert not hasattr(primitives, "removeprefix") + assert not hasattr(primitives, "removesuffix") + assert not hasattr(primitives, "bytestostr") + assert not hasattr(extended_data, "removeprefix") + assert not hasattr(extended_data, "removesuffix") + assert not hasattr(extended_data, "bytestostr") + old_type_converters = ( + "strtobool", + "strtodate", + "strtodatetime", + "strtofloat", + "strtoint", + "strtopath", + "strtotime", + ) + for old_name in old_type_converters: + assert not hasattr(primitives, old_name) + assert not hasattr(extended_data, old_name) -def test_root_exports_first_class_integrated_primitives() -> None: +def test_old_monorepo_import_namespaces_are_not_preserved() -> None: + """Old package import namespaces should remain absent in the clean major version.""" + old_namespaces = ( + "directed_inputs_class", + "extended_data_types", + "lifecyclelogging", + "vendor_connectors", + ) + + for namespace in old_namespaces: + assert util.find_spec(namespace) is None + + +def test_root_exports_first_class_integrated_surfaces() -> None: """Inputs, logging, and connector fabric are available from the root package.""" + assert extended_data.DataDecodeError.__name__ == "DataDecodeError" + assert extended_data.DataFile.__name__ == "DataFile" + assert extended_data.DataWorkflow.__name__ == "DataWorkflow" assert extended_data.InputProvider is InputProvider assert extended_data.Logging is Logging + assert extended_data.ConnectorBase is connectors.ConnectorBase assert extended_data.ConnectorFabric is ConnectorFabric + assert extended_data.ConnectorInfo.__name__ == "ConnectorInfo" + assert extended_data.WorkflowResult.__name__ == "WorkflowResult" + assert extended_data.WorkflowStep.__name__ == "WorkflowStep" + assert callable(extended_data.data_transform_action) + assert callable(extended_data.list_data_transform_steps) + assert "unhump" in extended_data.DATA_TRANSFORM_STEPS + assert "reconstruct" in extended_data.list_data_transform_steps() + assert extended_data.SecretsConnector is secrets.SecretsConnector + assert extended_data.SyncOptions is secrets.SyncOptions + assert extended_data.SyncResult is secrets.SyncResult + assert extended_data.SyncOperation is secrets.SyncOperation + assert extended_data.OutputFormat is secrets.OutputFormat assert callable(extended_data.directed_inputs) + assert callable(extended_data.read_data_file) assert callable(extended_data.get_connector) + assert callable(extended_data.list_available_connectors) + assert callable(extended_data.list_connector_info) + assert callable(extended_data.list_connector_categories) + assert callable(extended_data.list_connector_capabilities) + assert callable(extended_data.list_connectors_by_category) + assert callable(extended_data.list_connectors_by_capability) + connector_names = extended_data.list_connectors() + available_connector_names = extended_data.list_available_connectors() + connector_categories = extended_data.list_connector_categories() + connector_capabilities = extended_data.list_connector_capabilities() + cloud_connectors = extended_data.list_connectors_by_category("cloud") + repository_connectors = extended_data.list_connectors_by_capability("repositories") + assert isinstance(connector_names, ExtendedList) + assert isinstance(connector_names[0], ExtendedString) + assert isinstance(available_connector_names, ExtendedList) + assert isinstance(available_connector_names[0], ExtendedString) + assert set(available_connector_names) <= set(connector_names) + assert isinstance(connector_categories, ExtendedList) + assert isinstance(connector_categories[0], ExtendedString) + assert "cloud" in connector_categories + assert isinstance(connector_capabilities, ExtendedList) + assert isinstance(connector_capabilities[0], ExtendedString) + assert "repositories" in connector_capabilities + assert isinstance(cloud_connectors, ExtendedList) + assert isinstance(cloud_connectors[0], ExtendedDict) + assert "aws" in {connector["name"] for connector in cloud_connectors} + assert isinstance(repository_connectors, ExtendedList) + assert isinstance(repository_connectors[0], ExtendedDict) + assert "github" in {connector["name"] for connector in repository_connectors} + assert get_type_hints(connectors.list_connectors)["return"] == ExtendedList[ExtendedString] + assert get_type_hints(connectors.list_available_connectors)["return"] == ExtendedList[ExtendedString] + assert get_type_hints(connectors.list_connector_categories)["return"] == ExtendedList[ExtendedString] + assert get_type_hints(connectors.list_connector_capabilities)["return"] == ExtendedList[ExtendedString] + assert get_type_hints(ConnectorFabric.list_connectors)["return"] == ExtendedList[ExtendedString] + assert get_type_hints(ConnectorFabric.list_available_connectors)["return"] == ExtendedList[ExtendedString] + assert get_type_hints(ConnectorFabric.list_connector_categories)["return"] == ExtendedList[ExtendedString] + assert get_type_hints(ConnectorFabric.list_connector_capabilities)["return"] == ExtendedList[ExtendedString] + assert get_type_hints(ConnectorFabric.get_connector)["return"] is connectors.ConnectorBase + assert "cursor" in connector_names + + +def test_logging_exposes_stored_messages_as_detached_tier2_data() -> None: + """Stored log message collections should be consumable through Tier 2 containers.""" + logger = Logging(enable_console=False, enable_file=False) + + logger.logged_statement("Stored message", storage_marker="events", log_level="info") + messages = logger.get_stored_messages("events") + snapshot = logger.snapshot_stored_messages() + + messages.add("Local mutation") + + assert isinstance(messages, ExtendedSet) + assert isinstance(snapshot, ExtendedDict) + assert isinstance(snapshot["events"], ExtendedSet) + assert "Local mutation" not in logger.stored_messages["events"] + assert sorted(snapshot.to_export_safe()["events"]) == ["Stored message"] + + +def test_workflow_result_exposes_detached_export_boundaries() -> None: + """Workflow results should expose promoted and export-safe value boundaries.""" + result = extended_data.DataWorkflow.from_value({"service": {"name": "api"}}).result() + + promoted = result.as_extended() + promoted["service"]["name"] = "worker" + + assert isinstance(promoted, ExtendedDict) + assert result.value["service"]["name"] == "api" + assert result.as_extended()["service"]["name"].upper_first() == "Api" + assert result.to_export_safe() == {"service": {"name": "api"}} + assert '"service"' in result.wrap_for_export(allow_encoding="json") + + +def test_tier2_container_methods_expose_integrated_primitives() -> None: + """Tier 2 containers should expose common primitive operations directly.""" + matched = ExtendedString("api-gateway").is_partial_match("gateway") + parsed_int = ExtendedString("42").to_int() + decoded_string = ExtendedString('{"service": "api"}').decode_json() + typed = ExtendedList(["api", 2]).split_by_type(primitive_only=True) + mapped = ExtendedTuple(("service", "region")).zipmap(("api", "us-east-1")) + first_entry = ExtendedDict({"empty": "", "service": "api"}).first_non_empty_entry("empty", "service") + selected = ExtendedList([None, "", {"service": "api"}]).first_non_empty() + reconstructed = ExtendedDict({"enabled": "true", "retries": "5"}).reconstruct_special_types() + export_safe = ExtendedDict({"launched": "2026-06-10"}).reconstruct_special_types().to_export_safe() + + assert matched is True + assert parsed_int == 42 + assert isinstance(decoded_string, ExtendedDict) + assert decoded_string["service"].upper_first() == "Api" + assert isinstance(typed, ExtendedDict) + assert typed["str"] == ["api"] + assert isinstance(mapped, ExtendedDict) + assert mapped["service"].upper_first() == "Api" + assert isinstance(first_entry, ExtendedDict) + assert first_entry["service"].upper_first() == "Api" + assert isinstance(selected, ExtendedDict) + assert selected["service"].upper_first() == "Api" + assert isinstance(reconstructed, ExtendedDict) + assert reconstructed == {"enabled": True, "retries": 5} + assert export_safe == {"launched": "2026-06-10"} + + +def test_redaction_is_a_tier1_primitive_not_connector_local() -> None: + """Diagnostic redaction should live with reusable Tier 1 utilities.""" + assert primitives.redact_sensitive_text("password=hunter2") == "password=[REDACTED]" + assert primitives.redact_sensitive_data({"api_key": "key_123"}) == {"api_key": "[REDACTED]"} + assert util.find_spec("extended_data.connectors.redaction") is None + assert not hasattr(connectors, "redact_sensitive_text") + + +def test_connectors_root_exports_builtin_connector_classes() -> None: + """Every built-in registry connector class is exported from the connector package root.""" + for spec in BUILTIN_CONNECTORS.values(): + value = getattr(connectors, spec.class_name) + + assert isinstance(value, type) + assert value.__name__ == spec.class_name + + +def test_package_root_exports_builtin_connector_classes() -> None: + """Built-in connector classes are first-class root package exports.""" + for spec in BUILTIN_CONNECTORS.values(): + root_value = getattr(extended_data, spec.class_name) + connector_value = getattr(connectors, spec.class_name) + + assert root_value is connector_value + + +def test_first_class_connectors_keep_operation_mixins_without_optional_extras() -> None: + """Unified connector classes should expose real operation mixins before SDK extras are installed.""" + assert callable(connectors.AWSConnector.list_s3_buckets) + assert callable(connectors.AWSConnector.get_organization_accounts) + assert callable(connectors.AWSConnector.list_sso_users) + assert callable(connectors.GoogleConnector.list_projects) + assert callable(connectors.GoogleConnector.list_users) + assert callable(connectors.GoogleConnector.list_billing_accounts) + + +def test_google_registry_uses_single_first_class_connector() -> None: + """Google Workspace, Cloud, and Billing operations should not be split into connector aliases.""" + connector_names = {connector["name"] for connector in connectors.list_connector_info()} + + assert "google" in connector_names + assert "google_cloud" not in connector_names + assert "google_workspace" not in connector_names + assert "google_billing" not in connector_names + + +def test_clean_major_version_does_not_preserve_duplicate_tool_modules() -> None: + """Secrets tool factories live on the package root and connector implementation module.""" + assert util.find_spec("extended_data.secrets.tools") is None + assert callable(secrets.get_tools) + assert callable(secrets.get_langchain_tools) + assert callable(secrets.get_crewai_tools) + assert callable(secrets.get_strands_tools) + assert callable(connectors.SecretsConnector) diff --git a/tests/core/test_redaction.py b/tests/core/test_redaction.py new file mode 100644 index 0000000..e287a52 --- /dev/null +++ b/tests/core/test_redaction.py @@ -0,0 +1,85 @@ +"""Tests for Tier 1 redaction helpers.""" + +from __future__ import annotations + +from extended_data.primitives.redaction import redact_sensitive_data, redact_sensitive_text + + +def test_redact_sensitive_text_preserves_json_shape() -> None: + """Terminal text redaction should keep JSON-ish values parseable.""" + message = '{"password": "hunter2", "id_token": 12345, "Authorization": Bearer raw_token}' + + redacted = redact_sensitive_text(message) + + assert "hunter2" not in redacted + assert "12345" not in redacted + assert "raw_token" not in redacted + assert '"password": "[REDACTED]"' in redacted + assert '"id_token": "[REDACTED]"' in redacted + assert '"Authorization": "[REDACTED]"' in redacted + + +def test_redact_sensitive_text_accepts_known_diagnostic_values() -> None: + """Callers can redact known resource identifiers that are sensitive in context.""" + message = "failed for user@example.com and user%40example.com with token=raw_token" + + redacted = redact_sensitive_text(message, values=["user@example.com"]) + + assert "user@example.com" not in redacted + assert "user%40example.com" not in redacted + assert "raw_token" not in redacted + assert redacted.count("[REDACTED]") == 3 + + +def test_redact_sensitive_text_preserves_non_secret_url_query_values() -> None: + """Key/value redaction should not consume unrelated URL query parameters.""" + message = "https://example.com/config.json?api_key=key_123®ion=us-east-1" + + redacted = redact_sensitive_text(message) + + assert "key_123" not in redacted + assert "api_key=[REDACTED]" in redacted + assert "region=us-east-1" in redacted + + +def test_redact_sensitive_text_flattens_nested_known_values() -> None: + """Caller-provided diagnostic context can be nested like CLI or MCP arguments.""" + message = "failed for user@example.com at /tmp/private%2Fpath using prompt Fix login" + + redacted = redact_sensitive_text( + message, + values=[{"email": "user@example.com", "paths": ["/tmp/private/path"], "prompt": "Fix login"}], + ) + + assert "user@example.com" not in redacted + assert "/tmp/private%2Fpath" not in redacted + assert "Fix login" not in redacted + assert redacted.count("[REDACTED]") == 3 + + +def test_redact_sensitive_data_recurses_through_json_like_payloads() -> None: + """Structured redaction should handle nested JSON-like data.""" + payload = { + "password": "hunter2", + "nested": [{"api_key": "key_123", "value": "ok"}], + "headers": {"authorization": "Bearer raw_token"}, + "message": "client_secret=secret_123", + } + + redacted = redact_sensitive_data(payload) + + assert redacted == { + "password": "[REDACTED]", + "nested": [{"api_key": "[REDACTED]", "value": "ok"}], + "headers": {"authorization": "[REDACTED]"}, + "message": "client_secret=[REDACTED]", + } + + +def test_redact_sensitive_data_applies_known_values_recursively() -> None: + """Structured redaction should carry caller-provided sensitive values through nested text.""" + payload = {"details": ["private/path", {"message": "see private%2Fpath"}]} + + redacted = redact_sensitive_data(payload, values=["private/path"]) + + assert redacted == {"details": ["[REDACTED]", {"message": "see [REDACTED]"}]} diff --git a/tests/core/test_release_hygiene.py b/tests/core/test_release_hygiene.py new file mode 100644 index 0000000..1173259 --- /dev/null +++ b/tests/core/test_release_hygiene.py @@ -0,0 +1,457 @@ +"""Release hygiene checks for repository automation.""" + +from __future__ import annotations + +import re + +from importlib import import_module, resources +from pathlib import Path + +import tomlkit + + +REPO_ROOT = Path(__file__).resolve().parents[2] +WORKFLOW_ROOT = REPO_ROOT / ".github" / "workflows" +ACTION_REF_WITH_COMMENT_RE = re.compile(r"^\s*(?:-\s*)?uses:\s*([^#\s]+)(?:\s+#\s*(\S+))?") +PINNED_SHA_RE = re.compile(r"^[0-9a-f]{40}$") +ACTION_VERSION_COMMENT_RE = re.compile(r"^v\d+\.\d+\.\d+$") +PIN_TABLE_RE = re.compile(r"^\|\s*`([^`]+)`\s*\|\s*`([^`]+)`\s*\|\s*`([0-9a-f]{40})`\s*\|$") +PUBLIC_TEXT_ROOTS = ( + REPO_ROOT / "src", + REPO_ROOT / "docs", + REPO_ROOT / "examples", + REPO_ROOT / "README.md", +) +OLD_PROJECT_TERMS = ("extended-data-library", "terraform-modules", "TerraformDataSource") +OLD_PUBLIC_API_NAMES = ("VendorConnectorBase",) +OLD_PACKAGE_NAMESPACES = ( + "directed_inputs_class", + "extended_data_types", + "lifecyclelogging", + "vendor_connectors", +) +REMOVED_PUBLIC_KEYWORDS = ("prefer_native", "unhump_results") +FUTURE_API_PROMISES = ("will be available", "coming soon") +BOOTSTRAP_TEXT_MARKERS = ("(NEW)",) +EXTRACTION_ERA_FRAMING = ( + "remaining migration work", + "unfinished migration work", +) +IMPRECISE_VENDOR_FRAMING = ( + "vendor data connectors", + "vendor workflows", + "vendor integrations", + "vendor-specific", + "vendor data payloads", + "vendor data operations", + "vendor payload handles", + "vendor resource", + "structured vendor payloads", + "vendor or AI layers", +) +SECRETSSYNC_PROJECT_PATTERNS = ( + re.compile(r"\bsecretssync\s+(?:Go\s+)?(?:project|library|repo|repository|CLI|connector|bindings?)\b", re.IGNORECASE), + re.compile(r"\b(?:project|library|repo|repository|CLI|connector|bindings?)\s+secretssync\b", re.IGNORECASE), +) +IMPRECISE_SECRETSSYNC_TERMS = ("secret sync primitives",) +EXTRA_REFERENCE_RE = re.compile(r"extended-data\[([^\]\n]+)\]") +NON_RUNTIME_EXTRAS = {"all", "dev", "tests", "typing"} +PACKAGE_SHAPE_RE = re.compile(r"^ ([a-z_]+)/\s+") +UNPATCHED_RUNTIME_VULNERABILITIES = { + "chromadb": "GHSA-f4j7-r4q5-qw2c", + "torch": "GHSA-rrmf-rvhw-rf47", +} + + +def _pyproject() -> tomlkit.TOMLDocument: + return tomlkit.parse((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + + +def _uv_lock() -> tomlkit.TOMLDocument: + return tomlkit.parse((REPO_ROOT / "uv.lock").read_text(encoding="utf-8")) + + +def _requirement_name(requirement: str) -> str: + name_chars: list[str] = [] + for char in requirement: + if char.isalnum() or char in {"-", "_", "."}: + name_chars.append(char) + continue + break + return "".join(name_chars).lower().replace("_", "-") + + +def _workflow_action_pins() -> dict[str, tuple[str, str]]: + pins: dict[str, tuple[str, str]] = {} + offenders: list[str] = [] + + for path in sorted(WORKFLOW_ROOT.glob("*.yml")) + sorted(WORKFLOW_ROOT.glob("*.yaml")): + for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + match = ACTION_REF_WITH_COMMENT_RE.match(line) + if match is None: + continue + + uses = match.group(1).strip() + if uses.startswith(("./", "docker://")): + continue + + action, separator, ref = uses.rpartition("@") + version = match.group(2) + if not separator or PINNED_SHA_RE.fullmatch(ref) is None: + relative_path = path.relative_to(REPO_ROOT) + offenders.append(f"{relative_path}:{line_number}: {uses}") + continue + if version is None or ACTION_VERSION_COMMENT_RE.fullmatch(version) is None: + relative_path = path.relative_to(REPO_ROOT) + offenders.append(f"{relative_path}:{line_number}: missing stable version comment for {uses}") + continue + + existing = pins.setdefault(action, (version, ref)) + if existing != (version, ref): + relative_path = path.relative_to(REPO_ROOT) + offenders.append(f"{relative_path}:{line_number}: conflicting pin for {action}") + + assert offenders == [] + return pins + + +def _publishing_checklist_pins() -> dict[str, tuple[str, str]]: + pins: dict[str, tuple[str, str]] = {} + checklist = (REPO_ROOT / "docs" / "PUBLISHING_CHECKLIST.md").read_text(encoding="utf-8") + + for line in checklist.splitlines(): + match = PIN_TABLE_RE.match(line.strip()) + if match is None: + continue + action, version, ref = match.groups() + pins[action] = (version, ref) + + assert pins, "docs/PUBLISHING_CHECKLIST.md must list current workflow action pins" + return pins + + +def test_workflow_actions_are_pinned_to_exact_shas() -> None: + """Remote workflow actions should use immutable action commit SHAs.""" + assert _workflow_action_pins() + + +def test_publishing_checklist_matches_workflow_action_pins() -> None: + """The release checklist should document the exact workflow action pins.""" + assert _publishing_checklist_pins() == _workflow_action_pins() + + +def test_release_workflow_uses_pypi_trusted_publishing() -> None: + """Publishing should use PyPI trusted publishing instead of repository tokens.""" + release_workflow = (WORKFLOW_ROOT / "release.yml").read_text(encoding="utf-8") + forbidden_token_markers = ( + "PYPI_API_TOKEN", + "PYPI_TOKEN", + "pypi-token", + "pypi_token", + "__token__", + "secrets.PYPI", + ) + + assert "id-token: write" in release_workflow + assert "uv publish --trusted-publishing always" in release_workflow + assert "uv publish" in release_workflow + assert all(marker not in release_workflow for marker in forbidden_token_markers) + + +def test_public_text_does_not_reference_old_project_origins() -> None: + """Public code/docs should describe current Extended Data surfaces, not origin packages.""" + offenders: list[str] = [] + + paths: list[Path] = [] + for root in PUBLIC_TEXT_ROOTS: + if root.is_file(): + paths.append(root) + else: + paths.extend(path for path in root.rglob("*") if path.is_file()) + + for path in sorted(paths): + if path.suffix in {".pyc", ".png"}: + continue + text = path.read_text(encoding="utf-8") + for term in (*OLD_PROJECT_TERMS, *OLD_PUBLIC_API_NAMES): + if term in text: + offenders.append(f"{path.relative_to(REPO_ROOT)}: {term}") + + assert offenders == [] + + +def test_old_package_namespace_shims_do_not_exist() -> None: + """Clean major-version breaks should not grow old import namespace shims.""" + offenders: list[str] = [] + + for namespace in OLD_PACKAGE_NAMESPACES: + package_path = REPO_ROOT / "src" / namespace + module_path = REPO_ROOT / "src" / f"{namespace}.py" + if package_path.exists(): + offenders.append(str(package_path.relative_to(REPO_ROOT))) + if module_path.exists(): + offenders.append(str(module_path.relative_to(REPO_ROOT))) + + assert offenders == [] + + +def test_typed_classifier_has_pep561_marker() -> None: + """The typed package classifier should be backed by a PEP 561 marker.""" + classifiers = _pyproject()["project"]["classifiers"] + + assert "Typing :: Typed" in classifiers + assert (REPO_ROOT / "src" / "extended_data" / "py.typed").is_file() + assert resources.files("extended_data").joinpath("py.typed").is_file() + + +def test_all_extra_contains_every_runtime_extra_dependency() -> None: + """The broad install target should be the union of runtime feature extras.""" + extras = _pyproject()["project"]["optional-dependencies"] + all_dependencies = {str(dependency) for dependency in extras["all"]} + missing: list[str] = [] + + for extra_name, dependencies in extras.items(): + if extra_name in NON_RUNTIME_EXTRAS: + continue + + for dependency in dependencies: + dependency_text = str(dependency) + if dependency_text not in all_dependencies: + missing.append(f"{extra_name}: {dependency_text}") + + assert missing == [] + + +def test_dependency_manifests_do_not_lock_unpatched_runtime_vulnerabilities() -> None: + """Runtime dependency manifests should not carry known unpatched vulnerable packages.""" + vulnerable = set(UNPATCHED_RUNTIME_VULNERABILITIES) + offenders: list[str] = [] + project = _pyproject()["project"] + + for dependency in project["dependencies"]: + name = _requirement_name(str(dependency)) + if name in vulnerable: + offenders.append(f"pyproject.toml dependency {dependency}: {UNPATCHED_RUNTIME_VULNERABILITIES[name]}") + + for extra_name, dependencies in project["optional-dependencies"].items(): + for dependency in dependencies: + name = _requirement_name(str(dependency)) + if name in vulnerable: + offenders.append(f"pyproject.toml extra {extra_name} dependency {dependency}: {UNPATCHED_RUNTIME_VULNERABILITIES[name]}") + + for package in _uv_lock()["package"]: + name = str(package["name"]).lower().replace("_", "-") + if name in vulnerable: + offenders.append(f"uv.lock package {name}: {UNPATCHED_RUNTIME_VULNERABILITIES[name]}") + + assert offenders == [] + + +def test_public_install_guidance_names_known_extras() -> None: + """Static install examples should not teach extras that pyproject does not publish.""" + known_extras = set(_pyproject()["project"]["optional-dependencies"]) + offenders: list[str] = [] + paths = [REPO_ROOT / "README.md"] + paths.extend(path for root in (REPO_ROOT / "docs", REPO_ROOT / "examples", REPO_ROOT / "src") for path in root.rglob("*")) + + for path in sorted(path for path in paths if path.is_file()): + if path.suffix in {".pyc", ".png"}: + continue + + relative_path = path.relative_to(REPO_ROOT) + for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + for match in EXTRA_REFERENCE_RE.finditer(line): + extra_group = match.group(1) + if "..." in extra_group or "{" in extra_group or "}" in extra_group: + continue + + for extra in (part.strip() for part in extra_group.split(",")): + if extra and extra not in known_extras: + offenders.append(f"{relative_path}:{line_number}: {extra} in extended-data[{extra_group}]") + + assert offenders == [] + + +def test_public_install_guidance_documents_every_runtime_extra() -> None: + """Every runtime optional extra should be discoverable from public install guidance.""" + runtime_extras = set(_pyproject()["project"]["optional-dependencies"]) - NON_RUNTIME_EXTRAS + documented_extras: set[str] = set() + text = "\n".join( + [ + (REPO_ROOT / "README.md").read_text(encoding="utf-8"), + (REPO_ROOT / "docs" / "package-surface.md").read_text(encoding="utf-8"), + ], + ) + + for match in EXTRA_REFERENCE_RE.finditer(text): + extra_group = match.group(1) + if "..." in extra_group or "{" in extra_group or "}" in extra_group: + continue + documented_extras.update(extra.strip() for extra in extra_group.split(",") if extra.strip()) + + assert runtime_extras <= documented_extras + + +def test_project_scripts_point_to_callables() -> None: + """Console-script metadata should resolve to importable callables.""" + scripts = _pyproject()["project"]["scripts"] + offenders: list[str] = [] + + for script_name, target in scripts.items(): + module_name, separator, attribute_name = str(target).partition(":") + if not separator: + offenders.append(f"{script_name}: {target} has no attribute separator") + continue + + try: + module = import_module(module_name) + except Exception as exc: + offenders.append(f"{script_name}: cannot import {module_name}: {exc}") + continue + + entry_point = getattr(module, attribute_name, None) + if not callable(entry_point): + offenders.append(f"{script_name}: {target} is not callable") + + assert offenders == [] + + +def test_project_scripts_preserve_package_cli_boundaries() -> None: + """The broad CLI entrypoint should not regress to a connector-only module.""" + scripts = {str(name): str(target) for name, target in _pyproject()["project"]["scripts"].items()} + + assert scripts == { + "extended-data": "extended_data.cli:main", + "extended-data-mcp": "extended_data.connectors.mcp:main", + "meshy-mcp": "extended_data.connectors.meshy.mcp:main", + } + + +def test_readme_package_shape_matches_public_subpackages() -> None: + """The documented tier layout should match the actual top-level package directories.""" + source_root = REPO_ROOT / "src" / "extended_data" + actual_subpackages = { + path.name + for path in source_root.iterdir() + if path.is_dir() and not path.name.startswith("__") and (path / "__init__.py").is_file() + } + readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8") + try: + package_shape = readme.split("## Package Shape", 1)[1].split("```", 2)[1] + except IndexError as exc: + raise AssertionError("README.md must document the package shape in a fenced block") from exc + + documented_subpackages = { + match.group(1) for line in package_shape.splitlines() if (match := PACKAGE_SHAPE_RE.match(line)) + } + + assert documented_subpackages == actual_subpackages + + +def test_public_guidance_does_not_use_removed_runtime_keywords() -> None: + """Docs and examples should not keep teaching removed compatibility keywords.""" + offenders: list[str] = [] + paths = [REPO_ROOT / "README.md"] + paths.extend(path for root in (REPO_ROOT / "docs", REPO_ROOT / "examples") for path in root.rglob("*")) + + for path in sorted(path for path in paths if path.is_file()): + if path.suffix in {".pyc", ".png"}: + continue + text = path.read_text(encoding="utf-8") + for keyword in REMOVED_PUBLIC_KEYWORDS: + if keyword in text: + offenders.append(f"{path.relative_to(REPO_ROOT)}: {keyword}") + + assert offenders == [] + + +def test_public_guidance_uses_integrated_connector_framing() -> None: + """Public docs should frame connectors as integrated external-data surfaces.""" + offenders: list[str] = [] + paths = [REPO_ROOT / "README.md"] + paths.extend(path for root in (REPO_ROOT / "docs", REPO_ROOT / "examples", REPO_ROOT / "src") for path in root.rglob("*")) + + for path in sorted(path for path in paths if path.is_file()): + if path.suffix in {".pyc", ".png"}: + continue + text = path.read_text(encoding="utf-8") + for phrase in IMPRECISE_VENDOR_FRAMING: + if phrase in text: + offenders.append(f"{path.relative_to(REPO_ROOT)}: {phrase}") + + assert offenders == [] + + +def test_public_guidance_uses_standalone_package_framing() -> None: + """Public docs should not frame Extended Data as an extraction artifact.""" + offenders: list[str] = [] + paths = [REPO_ROOT / "README.md"] + paths.extend(path for root in (REPO_ROOT / "docs", REPO_ROOT / "examples", REPO_ROOT / "src") for path in root.rglob("*")) + + for path in sorted(path for path in paths if path.is_file()): + if path.suffix in {".pyc", ".png"}: + continue + text = path.read_text(encoding="utf-8") + for phrase in EXTRACTION_ERA_FRAMING: + if phrase in text: + offenders.append(f"{path.relative_to(REPO_ROOT)}: {phrase}") + + assert offenders == [] + + +def test_public_text_does_not_promise_future_api_surfaces() -> None: + """Clean-break docs should describe current surfaces instead of placeholders.""" + offenders: list[str] = [] + paths = [REPO_ROOT / "README.md"] + paths.extend(path for root in (REPO_ROOT / "docs", REPO_ROOT / "examples", REPO_ROOT / "src") for path in root.rglob("*")) + + for path in sorted(path for path in paths if path.is_file()): + if path.suffix in {".pyc", ".png"}: + continue + relative_path = path.relative_to(REPO_ROOT) + for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + normalized = line.lower() + for phrase in FUTURE_API_PROMISES: + if phrase in normalized: + offenders.append(f"{relative_path}:{line_number}: {phrase}") + + assert offenders == [] + + +def test_public_text_does_not_keep_bootstrap_markers() -> None: + """Extracted package docs should not keep launch-era status markers.""" + offenders: list[str] = [] + paths = [REPO_ROOT / "README.md"] + paths.extend(path for root in (REPO_ROOT / "docs", REPO_ROOT / "examples", REPO_ROOT / "src") for path in root.rglob("*")) + + for path in sorted(path for path in paths if path.is_file()): + if path.suffix in {".pyc", ".png"}: + continue + relative_path = path.relative_to(REPO_ROOT) + for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + for marker in BOOTSTRAP_TEXT_MARKERS: + if marker in line: + offenders.append(f"{relative_path}:{line_number}: {marker}") + + assert offenders == [] + + +def test_public_guidance_names_secrets_sync_roles_precisely() -> None: + """Use SecretSync for the product and reserve exact names for CLI modules.""" + offenders: list[str] = [] + paths = [REPO_ROOT / "README.md"] + paths.extend(path for root in (REPO_ROOT / "docs", REPO_ROOT / "src") for path in root.rglob("*")) + + for path in sorted(path for path in paths if path.is_file()): + if path.suffix in {".pyc", ".png"}: + continue + text = path.read_text(encoding="utf-8") + for pattern in SECRETSSYNC_PROJECT_PATTERNS: + if pattern.search(text): + offenders.append(str(path.relative_to(REPO_ROOT))) + break + for term in IMPRECISE_SECRETSSYNC_TERMS: + if term in text.lower(): + offenders.append(f"{path.relative_to(REPO_ROOT)}: {term}") + + assert offenders == [] diff --git a/tests/core/test_serialization_utils.py b/tests/core/test_serialization_utils.py index bd79ace..d457d9b 100644 --- a/tests/core/test_serialization_utils.py +++ b/tests/core/test_serialization_utils.py @@ -2,7 +2,7 @@ from __future__ import annotations -from extended_data.serialization_utils import normalize_data_encoding +from extended_data.primitives.serialization import normalize_data_encoding def test_normalize_data_encoding_aliases_and_passthrough() -> None: diff --git a/tests/core/test_splitter_utils.py b/tests/core/test_splitter_utils.py index 0f1dcf7..5cec76e 100644 --- a/tests/core/test_splitter_utils.py +++ b/tests/core/test_splitter_utils.py @@ -15,7 +15,7 @@ import pytest -from extended_data.splitter_utils import split_dict_by_type, split_list_by_type +from extended_data.primitives.splitting import split_dict_by_type, split_list_by_type @pytest.mark.parametrize( diff --git a/tests/core/test_stack_utils.py b/tests/core/test_stack_utils.py index b21fadf..e4806da 100644 --- a/tests/core/test_stack_utils.py +++ b/tests/core/test_stack_utils.py @@ -19,7 +19,7 @@ import pytest -from extended_data.stack_utils import ( +from extended_data.primitives.introspection import ( current_python_version_is_at_least, filter_methods, get_available_methods, diff --git a/tests/core/test_state_utils.py b/tests/core/test_state_utils.py index d883d99..d6ee079 100644 --- a/tests/core/test_state_utils.py +++ b/tests/core/test_state_utils.py @@ -23,7 +23,7 @@ import pytest -from extended_data.state_utils import ( +from extended_data.primitives.state import ( all_non_empty, any_non_empty, are_nothing, @@ -96,7 +96,7 @@ def test_are_nothing_with_no_inputs_returns_true() -> None: def test_are_nothing_fallback_branch_returns_false(mocker) -> None: """Defensively return False for unexpected all_non_empty output types.""" - mocker.patch("extended_data.state_utils.all_non_empty", return_value="unexpected") + mocker.patch("extended_data.primitives.state.all_non_empty", return_value="unexpected") assert are_nothing("value") is False diff --git a/tests/core/test_string_data_type.py b/tests/core/test_string_data_type.py index 3a355e6..a3e3fbb 100644 --- a/tests/core/test_string_data_type.py +++ b/tests/core/test_string_data_type.py @@ -1,4 +1,4 @@ -"""Test Suite for Extended Data Types - String Operations +"""Test suite for extended-data string operations. This module contains test functions and fixtures for verifying the functionality of various string operations provided by the `extended_data` package. The module covers a wide range of string manipulation and validation @@ -14,14 +14,12 @@ - `upper_first_char_data`: Provides input strings and expected results for testing uppercase conversion of the first character. - `url_data`: Provides URLs and expected validation results for testing URL checks. - `titleize_name_data`: Provides camelCase names and expected TitleCase results for testing titleization. - - `strtobool_data`: Provides strings representing truth values for testing boolean conversion. - - `strtofloat_data`: Provides strings representing floats for testing float conversion. - - `strtoint_data`: Provides strings representing integers for testing integer conversion. + - `string_to_bool_data`: Provides strings representing truth values for testing boolean conversion. + - `string_to_float_data`: Provides strings representing floats for testing float conversion. + - `string_to_int_data`: Provides strings representing integers for testing integer conversion. - `valid_path_data`: Provides valid input values and expected results for testing path conversion. - `invalid_path_data`: Provides invalid inputs and expected exceptions for testing path conversion with errors. - `silent_invalid_path_data`: Provides invalid inputs for testing path conversion when errors are silenced. - - `removeprefix_data`: Provides input strings, prefixes, and expected results for testing prefix removal. - - `removesuffix_data`: Provides input strings, suffixes, and expected results for testing suffix removal. ### Test Functions The module contains the following test functions: @@ -31,14 +29,12 @@ - `test_upper_first_char`: Tests converting the first character of a string to uppercase. - `test_is_url`: Tests checking if a string is a valid URL. - `test_titleize_name`: Tests converting camelCase names to TitleCase. - - `test_strtobool`: Tests converting a string to a boolean value. - - `test_strtofloat`: Tests converting a string to a float value. - - `test_strtoint`: Tests converting a string to an integer value. - - `test_strtopath`: Tests converting valid inputs into pathlib.Path objects. - - `test_strtopath_invalid`: Tests handling invalid path inputs that should raise exceptions. - - `test_strtopath_invalid_silent`: Tests handling invalid path inputs when errors are silenced. - - `test_removeprefix`: Tests removing a prefix from a string. - - `test_removesuffix`: Tests removing a suffix from a string. + - `test_string_to_bool`: Tests converting a string to a boolean value. + - `test_string_to_float`: Tests converting a string to a float value. + - `test_string_to_int`: Tests converting a string to an integer value. + - `test_string_to_path`: Tests converting valid inputs into pathlib.Path objects. + - `test_string_to_path_invalid`: Tests handling invalid path inputs that should raise exceptions. + - `test_string_to_path_invalid_silent`: Tests handling invalid path inputs when errors are silenced. """ from __future__ import annotations @@ -47,12 +43,11 @@ import pytest -from extended_data.string_data_type import ( - bytestostr, +from extended_data.containers import ExtendedString +from extended_data.primitives.strings import ( + bytes_to_string, is_url, lower_first_char, - removeprefix, - removesuffix, sanitize_key, titleize_name, truncate, @@ -140,38 +135,6 @@ def titleize_name_data(request: Any) -> tuple[str, str]: return request.param -@pytest.fixture( - params=[ - ("test_string", "test_", "string"), - ("string", "test_", "string"), - ("test_string", "", "test_string"), - ] -) -def removeprefix_data(request: Any) -> tuple[str, str, str]: - """Provides data for testing removeprefix function. - - Yields: - tuple[str, str, str]: A tuple containing the input string, prefix, and expected result. - """ - return request.param - - -@pytest.fixture( - params=[ - ("test_string", "_string", "test"), - ("test", "_string", "test"), - ("test_string", "", "test_string"), - ] -) -def removesuffix_data(request: Any) -> tuple[str, str, str]: - """Provides data for testing removesuffix function. - - Yields: - tuple[str, str, str]: A tuple containing the input string, suffix, and expected result. - """ - return request.param - - @pytest.mark.parametrize( ("input_value", "expected_output"), [ @@ -181,7 +144,7 @@ def removesuffix_data(request: Any) -> tuple[str, str, str]: (memoryview(b"memoryview data"), "memoryview data"), # Memoryview input ], ) -def test_bytestostr(input_value: str | memoryview | bytes | bytearray, expected_output: str) -> None: +def test_bytes_to_string(input_value: str | memoryview | bytes | bytearray, expected_output: str) -> None: """Tests converting various byte-like objects and strings into a UTF-8 decoded string. Args: @@ -189,25 +152,25 @@ def test_bytestostr(input_value: str | memoryview | bytes | bytearray, expected_ expected_output (str): The expected UTF-8 decoded string. Asserts: - The result of bytestostr matches the expected UTF-8 decoded string for valid inputs. + The result of bytes_to_string matches the expected UTF-8 decoded string for valid inputs. """ - assert bytestostr(input_value) == expected_output + assert bytes_to_string(input_value) == expected_output -def test_bytestostr_invalid_bytes() -> None: +def test_bytes_to_string_invalid_bytes() -> None: """Tests handling of invalid byte sequences during conversion to string. Asserts: - The bytestostr function raises a ConversionError when invalid bytes cannot be decoded. + The bytes_to_string function raises a ConversionError when invalid bytes cannot be decoded. """ invalid_bytes = b"\x80invalid" with pytest.raises(UnicodeDecodeError): - bytestostr(invalid_bytes) + bytes_to_string(invalid_bytes) -def test_bytestostr_falls_back_to_string_conversion() -> None: +def test_bytes_to_string_falls_back_to_string_conversion() -> None: """Convert non-bytes objects with a plain string fallback.""" - assert bytestostr(123) == "123" + assert bytes_to_string(123) == "123" def test_sanitize_key(test_key: str, sanitized_key: str) -> None: @@ -293,27 +256,13 @@ def test_titleize_name(titleize_name_data: tuple[str, str]) -> None: assert titleize_name(name) == expected -def test_removeprefix(removeprefix_data: tuple[str, str, str]) -> None: - """Tests removing a prefix from a string. +def test_string_utilities_accept_extended_string_values() -> None: + """Tier 1 string utilities compose with Tier 2 ExtendedString values.""" + value = ExtendedString("helloWorld") - Args: - removeprefix_data (tuple[str, str, str]): A fixture providing the input string, prefix, and expected result. - - Asserts: - The result of removeprefix matches the expected string with the prefix removed. - """ - string, prefix, expected = removeprefix_data - assert removeprefix(string, prefix) == expected - - -def test_removesuffix(removesuffix_data: tuple[str, str, str]) -> None: - """Tests removing a suffix from a string. - - Args: - removesuffix_data (tuple[str, str, str]): A fixture providing the input string, suffix, and expected result. - - Asserts: - The result of removesuffix matches the expected string with the suffix removed. - """ - string, suffix, expected = removesuffix_data - assert removesuffix(string, suffix) == expected + assert sanitize_key(ExtendedString("key-with*invalid_chars")) == "key_with_invalid_chars" + assert truncate(ExtendedString("abcdef"), 4) == "a..." + assert lower_first_char(ExtendedString("Hello")) == "hello" + assert upper_first_char(ExtendedString("hello")) == "Hello" + assert is_url(ExtendedString("https://example.com")) + assert titleize_name(value) == "Hello World" diff --git a/tests/core/test_string_transformations.py b/tests/core/test_string_transformations.py index 5fc8b93..ec14153 100644 --- a/tests/core/test_string_transformations.py +++ b/tests/core/test_string_transformations.py @@ -4,7 +4,8 @@ import pytest -from extended_data.string_transformations import ( +from extended_data.containers import ExtendedString +from extended_data.primitives.string_transforms import ( humanize, ordinalize, pluralize, @@ -94,3 +95,17 @@ def test_ordinalize_rejects_non_numeric_values() -> None: """Reject non-numeric ordinal inputs.""" with pytest.raises(ValueError, match="ordinalize expects a numeric value"): ordinalize("forty-two") + + +def test_string_transforms_accept_extended_string_values() -> None: + """Tier 1 string transforms compose with Tier 2 ExtendedString values.""" + value = ExtendedString("helloWorld") + + assert to_snake_case(value) == "hello_world" + assert to_camel_case(ExtendedString("hello_world")) == "helloWorld" + assert to_pascal_case(value) == "HelloWorld" + assert to_kebab_case(value) == "hello-world" + assert pluralize(ExtendedString("book")) == "books" + assert singularize(ExtendedString("criteria")) == "criterion" + assert humanize(ExtendedString("api_key")) == "API key" + assert titleize(ExtendedString("HELLO WORLD")) == "Hello World" diff --git a/tests/core/test_toml_utils.py b/tests/core/test_toml_utils.py index 1105f05..4163aaa 100644 --- a/tests/core/test_toml_utils.py +++ b/tests/core/test_toml_utils.py @@ -17,24 +17,22 @@ import pytest import tomlkit -from extended_data.toml_utils import decode_toml, encode_toml +from extended_data.containers import ExtendedDict +from extended_data.primitives.formats.errors import DataDecodeError +from extended_data.primitives.formats.toml import decode_toml, encode_toml def test_decode_toml_invalid_format() -> None: - """Tests the `decode_toml` function with an invalid TOML format. - - This test checks whether the `decode_toml` function raises a `ParseError` - when provided with a malformed TOML string, specifically one that contains - an unclosed quote. - - Asserts: - The function raises `tomlkit.exceptions.ParseError` when decoding - the invalid TOML string. - """ - invalid_toml = "title = 'Unclosed quote" - with pytest.raises(tomlkit.exceptions.ParseError): + """Reject malformed TOML through a sanitized package error.""" + invalid_toml = "token = 'super-secret" + with pytest.raises(DataDecodeError) as exc_info: decode_toml(invalid_toml) + message = str(exc_info.value) + assert "Failed to decode TOML data" in message + assert "line 1" in message + assert "super-secret" not in message + def test_decode_toml_bytes_success() -> None: """Decode TOML from bytes.""" @@ -43,8 +41,8 @@ def test_decode_toml_bytes_success() -> None: def test_decode_toml_invalid_bytes() -> None: - """Raise a TOMLKitError when bytes cannot be decoded.""" - with pytest.raises(tomlkit.exceptions.TOMLKitError, match="Failed to decode bytes to string"): + """Raise a sanitized decode error when bytes cannot be decoded.""" + with pytest.raises(DataDecodeError, match="input bytes are not valid UTF-8"): decode_toml(b"\x80") @@ -73,3 +71,16 @@ def test_encode_toml_converts_tuple_like_composites() -> None: parsed = tomlkit.parse(result) assert parsed["items"] == ["alpha", "beta"] assert sorted(parsed["values"]) == [1, 2] + + +@pytest.mark.parametrize("use_data_attribute", [False, True]) +def test_encode_toml_lowers_extended_containers(use_data_attribute: bool) -> None: + """Encode Tier 2 containers through TOML's existing primitive normalization.""" + payload = ExtendedDict({"service": {"name": "api"}, "ports": [80, 443]}) + raw_data = payload.data if use_data_attribute else payload + + result = encode_toml(raw_data) + + parsed = tomlkit.parse(result) + assert parsed["service"]["name"] == "api" + assert parsed["ports"] == [80, 443] diff --git a/tests/core/test_type_utils.py b/tests/core/test_type_utils.py index 1a6ebe1..d800326 100644 --- a/tests/core/test_type_utils.py +++ b/tests/core/test_type_utils.py @@ -1,4 +1,4 @@ -"""Test suite for extended_data.type_utils module. +"""Test suite for extended_data.primitives.types module. This module contains unit tests for various utility functions provided by the type_utils module, ensuring correct functionality of type conversions, @@ -14,7 +14,9 @@ import pytest -from extended_data.type_utils import ( +from extended_data.containers import ExtendedDict, ExtendedList, ExtendedSet, ExtendedString, ExtendedTuple +from extended_data.primitives.formats.yaml import YamlPairs, YamlTagged +from extended_data.primitives.types import ( ConversionError, convert_special_type, convert_special_types, @@ -23,16 +25,15 @@ make_hashable, reconstruct_special_type, reconstruct_special_types, - strtobool, - strtodate, - strtodatetime, - strtofloat, - strtoint, - strtopath, - strtotime, + string_to_bool, + string_to_date, + string_to_datetime, + string_to_float, + string_to_int, + string_to_path, + string_to_time, typeof, ) -from extended_data.yaml_utils import YamlPairs, YamlTagged # Constants for expected test values @@ -43,8 +44,8 @@ @pytest.fixture(params=[("yes", True), ("no", False), ("invalid", None)]) -def strtobool_data(request: Any) -> tuple[str, bool | None]: - """Provides data for testing strtobool function. +def string_to_bool_data(request: Any) -> tuple[str, bool | None]: + """Provides data for testing string_to_bool function. Yields: tuple[str, bool | None]: A tuple containing the input string and the expected boolean or None result. @@ -53,8 +54,8 @@ def strtobool_data(request: Any) -> tuple[str, bool | None]: @pytest.fixture(params=[("3.14", EXPECTED_FLOAT_1), ("42", EXPECTED_FLOAT_2), ("invalid", None)]) -def strtofloat_data(request: Any) -> tuple[str, float | None]: - """Provides data for testing strtofloat function. +def string_to_float_data(request: Any) -> tuple[str, float | None]: + """Provides data for testing string_to_float function. Yields: tuple[str, float | None]: A tuple containing the input value and the expected float or None result. @@ -63,8 +64,8 @@ def strtofloat_data(request: Any) -> tuple[str, float | None]: @pytest.fixture(params=[("42", EXPECTED_INT_1), ("3.0", EXPECTED_INT_2), ("invalid", None)]) -def strtoint_data(request: Any) -> tuple[str, int | None]: - """Provides data for testing strtoint function. +def string_to_int_data(request: Any) -> tuple[str, int | None]: + """Provides data for testing string_to_int function. Yields: tuple[str, int | None]: A tuple containing the input value and the expected int or None result. @@ -81,7 +82,7 @@ def strtoint_data(request: Any) -> tuple[str, int | None]: ] ) def valid_path_data(request: Any) -> tuple[str | bytes | Path | None, Path | None]: - """Provides valid input and expected output pairs for testing strtopath function. + """Provides valid input and expected output pairs for testing string_to_path function. Yields: tuple[str | bytes | Path | None, Path | None]: A tuple containing the input value and the expected Path or None result. @@ -91,7 +92,7 @@ def valid_path_data(request: Any) -> tuple[str | bytes | Path | None, Path | Non @pytest.fixture(params=[("invalid:://path", ValueError, True), (b"\x80invalid", ValueError, True)]) def invalid_path_data(request: Any) -> tuple[str | bytes, type[Exception], bool]: - """Provides invalid input, expected exception type, and raise_on_error flag for testing strtopath. + """Provides invalid input, expected exception type, and raise_on_error flag for testing string_to_path. Yields: tuple[str | bytes, Type[Exception], bool]: A tuple containing the input value, expected exception type, and the raise_on_error flag. @@ -101,7 +102,7 @@ def invalid_path_data(request: Any) -> tuple[str | bytes, type[Exception], bool] @pytest.fixture(params=["invalid:://path", b"\x80invalid"]) def silent_invalid_path_data(request: Any) -> str | bytes: - """Provides invalid input values for testing strtopath when raise_on_error is False. + """Provides invalid input values for testing string_to_path when raise_on_error is False. Yields: str | bytes: The invalid input value to test. @@ -116,8 +117,8 @@ def silent_invalid_path_data(request: Any) -> str | bytes: ("invalid-date", None), ] ) -def strtodate_data(request: Any) -> tuple[str, datetime.date | None]: - """Provides data for testing strtodate function. +def string_to_date_data(request: Any) -> tuple[str, datetime.date | None]: + """Provides data for testing string_to_date function. Yields: tuple[str, datetime.date | None]: A tuple containing the input string and the expected date object or None. @@ -142,8 +143,8 @@ def strtodate_data(request: Any) -> tuple[str, datetime.date | None]: ("invalid-datetime", None), ] ) -def strtodatetime_data(request: Any) -> tuple[str, datetime.datetime | None]: - """Provides data for testing strtodatetime function. +def string_to_datetime_data(request: Any) -> tuple[str, datetime.datetime | None]: + """Provides data for testing string_to_datetime function. Yields: tuple[str, datetime.datetime | None]: A tuple containing the input string and the expected datetime object or None. @@ -159,8 +160,8 @@ def strtodatetime_data(request: Any) -> tuple[str, datetime.datetime | None]: ("invalid-time", None), ] ) -def strtotime_data(request: Any) -> tuple[str, datetime.time | None]: - """Provides data for testing strtotime function. +def string_to_time_data(request: Any) -> tuple[str, datetime.time | None]: + """Provides data for testing string_to_time function. Yields: tuple[str, datetime.time | None]: A tuple containing the input string and the expected time object or None. @@ -168,240 +169,259 @@ def strtotime_data(request: Any) -> tuple[str, datetime.time | None]: return request.param -def test_strtobool(strtobool_data: tuple[str, bool | None]) -> None: +def test_string_to_bool(string_to_bool_data: tuple[str, bool | None]) -> None: """Tests converting a string to a boolean value. Args: - strtobool_data (tuple[str, bool | None]): A fixture providing the input string and the expected boolean or None result. + string_to_bool_data (tuple[str, bool | None]): A fixture providing the input string and the expected boolean or None result. Asserts: - The result of strtobool is True for truthy strings, False for falsy strings, and raises a ConversionError for invalid strings if specified. + The result of string_to_bool is True for truthy strings, False for falsy strings, and raises a ConversionError for invalid strings if specified. """ - val, expected = strtobool_data - assert strtobool(val) == expected + val, expected = string_to_bool_data + assert string_to_bool(val) == expected if expected is None and val == "invalid": with pytest.raises(ConversionError, match=r"Invalid value: 'invalid'"): - strtobool(val, raise_on_error=True) + string_to_bool(val, raise_on_error=True) -def test_strtobool_passthrough_for_bool_and_none() -> None: +def test_string_to_bool_passthrough_for_bool_and_none() -> None: """Return boolean and None inputs unchanged.""" - assert strtobool(True) is True - assert strtobool(False) is False - assert strtobool(None) is None + assert string_to_bool(True) is True + assert string_to_bool(False) is False + assert string_to_bool(None) is None -def test_strtobool_rejects_non_strings_when_requested() -> None: +def test_string_to_bool_rejects_non_strings_when_requested() -> None: """Reject unsupported non-string inputs when raise_on_error is enabled.""" with pytest.raises(ConversionError, match=r"Invalid value: 123"): - strtobool(123, raise_on_error=True) + string_to_bool(123, raise_on_error=True) + + +def test_string_type_converters_accept_extended_string_values() -> None: + """Type conversion primitives compose with Tier 2 ExtendedString values.""" + assert string_to_bool(ExtendedString("true")) is True + assert string_to_float(ExtendedString("3.14")) == EXPECTED_FLOAT_1 + assert string_to_int(ExtendedString("42")) == EXPECTED_INT_1 + assert string_to_date(ExtendedString("2023-09-05")) == datetime.date(2023, 9, 5) + assert string_to_datetime(ExtendedString("2023-09-05T12:30:00")) == datetime.datetime( + 2023, + 9, + 5, + 12, + 30, + 0, + tzinfo=datetime.timezone.utc, + ) + assert string_to_time(ExtendedString("12:30")) == datetime.time(12, 30, 0) + assert string_to_path(ExtendedString("/valid/path")) == Path("/valid/path") -def test_strtofloat(strtofloat_data: tuple[str, float | None]) -> None: +def test_string_to_float(string_to_float_data: tuple[str, float | None]) -> None: """Tests converting a string to a float value. Args: - strtofloat_data (tuple[str, float | None]): A fixture providing the input value and the expected float or None result. + string_to_float_data (tuple[str, float | None]): A fixture providing the input value and the expected float or None result. Asserts: - The result of strtofloat matches the expected float value and raises a ConversionError for invalid strings if specified. + The result of string_to_float matches the expected float value and raises a ConversionError for invalid strings if specified. """ - val, expected = strtofloat_data - assert strtofloat(val) == expected + val, expected = string_to_float_data + assert string_to_float(val) == expected if expected is None and val == "invalid": with pytest.raises(ConversionError, match=r"Invalid value: 'invalid'"): - strtofloat(val, raise_on_error=True) + string_to_float(val, raise_on_error=True) -def test_strtofloat_wraps_float_value_errors(mocker) -> None: +def test_string_to_float_wraps_float_value_errors(mocker) -> None: """Surface float conversion failures as ConversionError when requested.""" mocker.patch("builtins.float", side_effect=ValueError("boom")) with pytest.raises(ConversionError, match=r"Invalid .* value: '3.14'"): - strtofloat("3.14", raise_on_error=True) + string_to_float("3.14", raise_on_error=True) -def test_strtofloat_swallows_float_value_errors_when_not_requested(mocker) -> None: +def test_string_to_float_swallows_float_value_errors_when_not_requested(mocker) -> None: """Return None when float conversion fails and raise_on_error is disabled.""" mocker.patch("builtins.float", side_effect=ValueError("boom")) - assert strtofloat("3.14") is None + assert string_to_float("3.14") is None -def test_strtoint(strtoint_data: tuple[str, int | None]) -> None: +def test_string_to_int(string_to_int_data: tuple[str, int | None]) -> None: """Tests converting a string to an integer value. Args: - strtoint_data (tuple[str, int | None]): A fixture providing the input value and the expected int or None result. + string_to_int_data (tuple[str, int | None]): A fixture providing the input value and the expected int or None result. Asserts: - The result of strtoint matches the expected integer value and raises a ConversionError for invalid strings if specified. + The result of string_to_int matches the expected integer value and raises a ConversionError for invalid strings if specified. """ - val, expected = strtoint_data - assert strtoint(val) == expected + val, expected = string_to_int_data + assert string_to_int(val) == expected if expected is None and val == "invalid": with pytest.raises(ConversionError, match=r"Invalid value: 'invalid'"): - strtoint(val, raise_on_error=True) + string_to_int(val, raise_on_error=True) -def test_strtoint_wraps_nested_conversion_errors(mocker) -> None: +def test_string_to_int_wraps_nested_conversion_errors(mocker) -> None: """Map nested float conversion failures to integer conversion failures.""" mocker.patch( - "extended_data.type_utils.strtofloat", + "extended_data.primitives.types.string_to_float", side_effect=ConversionError(float, "3.14"), ) with pytest.raises(ConversionError, match=r"Invalid value: '3.14'"): - strtoint("3.14", raise_on_error=True) + string_to_int("3.14", raise_on_error=True) -def test_strtoint_swallows_nested_conversion_errors_when_not_requested(mocker) -> None: +def test_string_to_int_swallows_nested_conversion_errors_when_not_requested(mocker) -> None: """Return None when nested conversion fails and raise_on_error is disabled.""" mocker.patch( - "extended_data.type_utils.strtofloat", + "extended_data.primitives.types.string_to_float", side_effect=ConversionError(float, "3.14"), ) - assert strtoint("3.14") is None + assert string_to_int("3.14") is None -def test_strtoint_raises_when_nested_conversion_returns_none(mocker) -> None: +def test_string_to_int_raises_when_nested_conversion_returns_none(mocker) -> None: """Raise an integer conversion error when nested conversion returns no value.""" - mocker.patch("extended_data.type_utils.strtofloat", return_value=None) + mocker.patch("extended_data.primitives.types.string_to_float", return_value=None) with pytest.raises(ConversionError, match=r"Invalid value: '3.14'"): - strtoint("3.14", raise_on_error=True) + string_to_int("3.14", raise_on_error=True) -def test_strtopath( +def test_string_to_path( valid_path_data: tuple[str | bytes | Path | None, Path | None], ) -> None: - """Tests the strtopath function for converting valid inputs into Path objects. + """Tests the string_to_path function for converting valid inputs into Path objects. Args: valid_path_data (tuple[str | bytes | Path | None, Path | None]): A fixture providing the input value and the expected Path or None result. Asserts: - The result of strtopath matches the expected Path object or None. + The result of string_to_path matches the expected Path object or None. """ value, expected = valid_path_data - assert strtopath(value) == expected + assert string_to_path(value) == expected -def test_strtopath_invalid( +def test_string_to_path_invalid( invalid_path_data: tuple[str | bytes, type[Exception], bool], ) -> None: - """Tests the strtopath function for handling invalid inputs that should raise exceptions. + """Tests the string_to_path function for handling invalid inputs that should raise exceptions. Args: invalid_path_data (tuple[str | bytes, Type[Exception], bool]): A fixture providing the input value, expected exception type, and the raise_on_error flag. Asserts: - The strtopath function raises the expected exception with the correct error message when the raise_on_error flag is set to True. + The string_to_path function raises the expected exception with the correct error message when the raise_on_error flag is set to True. """ value, expected_exception, raise_on_error = invalid_path_data with pytest.raises(expected_exception, match=r"Invalid value"): - strtopath(value, raise_on_error=raise_on_error) + string_to_path(value, raise_on_error=raise_on_error) -def test_strtopath_invalid_silent(silent_invalid_path_data: str | bytes) -> None: - """Tests the strtopath function with invalid inputs when fail_silently is set to True. +def test_string_to_path_invalid_silent(silent_invalid_path_data: str | bytes) -> None: + """Tests the string_to_path function with invalid inputs when fail_silently is set to True. Args: silent_invalid_path_data (str | bytes): A fixture providing the invalid input value to test. Asserts: - The strtopath function returns None when the input is invalid and the raise_on_error flag is False. + The string_to_path function returns None when the input is invalid and the raise_on_error flag is False. """ - assert strtopath(silent_invalid_path_data) is None + assert string_to_path(silent_invalid_path_data) is None -def test_strtodate(strtodate_data: tuple[str, datetime.date | None]) -> None: +def test_string_to_date(string_to_date_data: tuple[str, datetime.date | None]) -> None: """Tests converting a string to a date value. Args: - strtodate_data (tuple[str, datetime.date | None]): A fixture providing the input string and the expected date object or None. + string_to_date_data (tuple[str, datetime.date | None]): A fixture providing the input string and the expected date object or None. Asserts: - The result of strtodate matches the expected date value and raises a ConversionError for invalid strings if specified. + The result of string_to_date matches the expected date value and raises a ConversionError for invalid strings if specified. """ - val, expected = strtodate_data - assert strtodate(val) == expected + val, expected = string_to_date_data + assert string_to_date(val) == expected if expected is None and val == "invalid-date": with pytest.raises( ConversionError, match=r"Invalid value: 'invalid-date'", ): - strtodate(val, raise_on_error=True) + string_to_date(val, raise_on_error=True) -def test_strtodate_invalid_matching_pattern_raises() -> None: +def test_string_to_date_invalid_matching_pattern_raises() -> None: """Reject impossible calendar dates that still match the date pattern.""" - assert strtodate("2023-13-40") is None + assert string_to_date("2023-13-40") is None with pytest.raises(ConversionError, match=r"Invalid value: '2023-13-40'"): - strtodate("2023-13-40", raise_on_error=True) + string_to_date("2023-13-40", raise_on_error=True) -def test_strtodatetime( - strtodatetime_data: tuple[str, datetime.datetime | None], +def test_string_to_datetime( + string_to_datetime_data: tuple[str, datetime.datetime | None], ) -> None: """Tests converting a string to a datetime value. Args: - strtodatetime_data (tuple[str, datetime.datetime | None]): A fixture providing the input string and the expected datetime object or None. + string_to_datetime_data (tuple[str, datetime.datetime | None]): A fixture providing the input string and the expected datetime object or None. Asserts: - The result of strtodatetime matches the expected datetime value and raises a ConversionError for invalid strings if specified. + The result of string_to_datetime matches the expected datetime value and raises a ConversionError for invalid strings if specified. """ - val, expected = strtodatetime_data - assert strtodatetime(val) == expected + val, expected = string_to_datetime_data + assert string_to_datetime(val) == expected if expected is None and val == "invalid-datetime": with pytest.raises( ConversionError, match=r"Invalid value: 'invalid-datetime'", ): - strtodatetime(val, raise_on_error=True) + string_to_datetime(val, raise_on_error=True) -def test_strtodatetime_invalid_matching_pattern_raises() -> None: +def test_string_to_datetime_invalid_matching_pattern_raises() -> None: """Reject impossible datetimes that still match the datetime pattern.""" invalid_value = "2023-13-05T25:61:00" - assert strtodatetime(invalid_value) is None + assert string_to_datetime(invalid_value) is None with pytest.raises(ConversionError, match=r"Invalid value: '2023-13-05T25:61:00'"): - strtodatetime(invalid_value, raise_on_error=True) + string_to_datetime(invalid_value, raise_on_error=True) -def test_strtodatetime_preserves_explicit_timezone() -> None: +def test_string_to_datetime_preserves_explicit_timezone() -> None: """Keep explicit timezone offsets instead of forcing UTC.""" - result = strtodatetime("2023-09-05T12:30:00+02:00") + result = string_to_datetime("2023-09-05T12:30:00+02:00") assert result == datetime.datetime(2023, 9, 5, 12, 30, 0, tzinfo=datetime.timezone(datetime.timedelta(hours=2))) -def test_strtotime(strtotime_data: tuple[str, datetime.time | None]) -> None: +def test_string_to_time(string_to_time_data: tuple[str, datetime.time | None]) -> None: """Tests converting a string to a time value. Args: - strtotime_data (tuple[str, datetime.time | None]): A fixture providing the input string and the expected time object or None. + string_to_time_data (tuple[str, datetime.time | None]): A fixture providing the input string and the expected time object or None. Asserts: - The result of strtotime matches the expected time value and raises a ConversionError for invalid strings if specified. + The result of string_to_time matches the expected time value and raises a ConversionError for invalid strings if specified. """ - val, expected = strtotime_data - assert strtotime(val) == expected + val, expected = string_to_time_data + assert string_to_time(val) == expected if expected is None and val == "invalid-time": with pytest.raises( ConversionError, match=r"Invalid value: 'invalid-time'", ): - strtotime(val, raise_on_error=True) + string_to_time(val, raise_on_error=True) -def test_strtotime_invalid_matching_pattern_raises() -> None: +def test_string_to_time_invalid_matching_pattern_raises() -> None: """Reject impossible times that still match the time pattern.""" invalid_value = "25:61:00" - assert strtotime(invalid_value) is None + assert string_to_time(invalid_value) is None with pytest.raises(ConversionError, match=r"Invalid value: '25:61:00'"): - strtotime(invalid_value, raise_on_error=True) + string_to_time(invalid_value, raise_on_error=True) # Test for get_default_value_for_type function @@ -433,6 +453,11 @@ def test_get_default_value_for_type(input_type: type, expected: Any) -> None: ((1, 2, 3), list), ({"key": "value"}, dict), ({1, 2}, set), + (ExtendedString("hello"), str), + (ExtendedList([1, 2, 3]), list), + (ExtendedTuple((1, 2, 3)), list), + (ExtendedDict({"key": "value"}), dict), + (ExtendedSet({1, 2}), set), (None, type(None)), (object(), object), ], @@ -452,6 +477,12 @@ def test_get_primitive_type_for_instance_type(value: Any, expected_type: type) - ([1, 2, 3], True, list), ({"key": "value"}, False, dict), ({"key": "value"}, True, dict), + (ExtendedString("hello"), False, ExtendedString), + (ExtendedString("hello"), True, str), + (ExtendedList([1, 2, 3]), False, ExtendedList), + (ExtendedList([1, 2, 3]), True, list), + (ExtendedDict({"key": "value"}), False, ExtendedDict), + (ExtendedDict({"key": "value"}), True, dict), ], ) def test_typeof(item: Any, primitive_only: bool, expected_type: type) -> None: @@ -498,6 +529,23 @@ def test_convert_special_type_handles_mappings_and_sequences_directly() -> None: assert convert_special_type((Path("/tmp/a"), datetime.date(2025, 1, 15))) == ["/tmp/a", "2025-01-15"] +def test_convert_special_types_handles_extended_containers() -> None: + """Normalize Tier 2 containers without stringifying nested collections.""" + value = ExtendedDict( + { + "enabled": ExtendedString("true"), + "paths": ExtendedList([Path("/tmp/a"), datetime.date(2025, 1, 15)]), + "tags": ExtendedSet({ExtendedString("api")}), + } + ) + + result = convert_special_types(value) + + assert result["enabled"] == "true" + assert result["paths"] == ["/tmp/a", "2025-01-15"] + assert result["tags"] == ["api"] + + # Test for convert_special_types function @pytest.mark.parametrize( ("obj", "expected"), @@ -549,10 +597,13 @@ def test_convert_special_types_handles_tuple_frozenset_and_yaml_pairs() -> None: ("/some/path", Path("/some/path")), # Path string to Path ("simple string", "simple string"), # Simple string remains unchanged ("123", 123), # Numeric string to integer + ("-123", -123), # Negative numeric string to integer ("3.14", 3.14), # Numeric string to float + ("-3.14", -3.14), # Negative numeric string to float ("true", True), # Boolean string to bool ("false", False), ("None", None), # "None" string to NoneType + ("null", None), # JSON null string to NoneType ("", ""), # Empty string remains unchanged ], ) @@ -635,6 +686,25 @@ def test_reconstruct_special_types_handles_tuples_and_frozensets() -> None: assert frozenset_result == frozenset([datetime.date(2023, 9, 5), True]) +def test_reconstruct_special_types_handles_extended_containers() -> None: + """Reconstruct special values inside Tier 2 containers.""" + value = ExtendedDict( + { + "enabled": ExtendedString("true"), + "count": ExtendedString("5"), + "items": ExtendedList([ExtendedString("2023-09-05")]), + "tags": ExtendedSet({ExtendedString("false")}), + } + ) + + result = reconstruct_special_types(value, fail_silently=False) + + assert result["enabled"] is True + assert result["count"] == 5 + assert result["items"] == [datetime.date(2023, 9, 5)] + assert result["tags"] == {False} + + def test_reconstruct_special_types_leaves_non_container_values_alone() -> None: """Pass through values that do not need recursive reconstruction.""" assert reconstruct_special_types(123, fail_silently=False) == 123 diff --git a/tests/core/test_workflows.py b/tests/core/test_workflows.py index 9fab626..01d6db3 100644 --- a/tests/core/test_workflows.py +++ b/tests/core/test_workflows.py @@ -2,22 +2,345 @@ from __future__ import annotations +import datetime +import json + from pathlib import Path +import pytest + from extended_data import ( + DataFile, + DataWorkflow, + ExtendedDict, + ExtendedList, + ExtendedTuple, + WorkflowResult, + WorkflowStep, base64_decode, base64_encode, - decode_file, - decode_hcl2, - deduplicate_map, - deep_merge, - encode_hcl2, - filter_list, - read_file, - to_snake_case, + data_transform_action, + list_data_transform_steps, + read_data_file, write_file, ) -from extended_data.yaml_utils import YamlTagged +from extended_data.primitives import decode_hcl2, encode_hcl2 +from extended_data.primitives.formats.yaml import YamlTagged + + +def test_data_workflow_layered_config_round_trip(tmp_path: Path) -> None: + """DataWorkflow composes Tier 3 file IO with Tier 2 container transforms.""" + base_config = { + "service": {"name": "api", "debug": False}, + "ports": [8080], + "features": {"auth": True}, + } + env_config = { + "service": {"debug": True}, + "ports": [8081], + "features": {"metrics": True}, + } + + write_file("config/base.yaml", base_config, tld=tmp_path) + write_file("config/dev.yaml", env_config, tld=tmp_path) + + env_data = DataWorkflow.from_file("config/dev.yaml", tld=tmp_path).value + result = ( + DataWorkflow.from_file("config/base.yaml", tld=tmp_path) + .merge(env_data, name="merge-env") + .write("build/config.yaml", tld=tmp_path) + ) + + assert isinstance(result, WorkflowResult) + assert result.output_path == tmp_path / "build" / "config.yaml" + assert result.steps == ("read:config/base.yaml", "merge-env", "write:build/config.yaml") + assert result.as_builtin() == { + "service": {"name": "api", "debug": True}, + "ports": [8080, 8081], + "features": {"auth": True, "metrics": True}, + } + assert read_data_file(result.output_path) == result.as_builtin() + + +def test_data_workflow_runs_named_value_transforms() -> None: + """DataWorkflow can normalize in-memory API payloads through named steps.""" + raw_payload = { + "HTTPResponseCode": 200, + "SelectedServices": ["api", "worker", "db"], + "Tags": ["api", "api", "docs"], + } + + def select_services(data: ExtendedDict) -> ExtendedDict: + return data | {"SelectedServices": data["SelectedServices"].filter_values(denylist=["db"])} + + workflow = DataWorkflow.from_value(raw_payload).run( + ("select-services", select_services), + ("deduplicate", lambda data: data.deduplicate()), + ("unhump", lambda data: data.unhump()), + ) + result = workflow.result() + + assert workflow.steps == ("value", "select-services", "deduplicate", "unhump") + assert isinstance(workflow.value, ExtendedDict) + assert isinstance(workflow.value["selected_services"], ExtendedList) + assert result.as_builtin() == { + "http_response_code": 200, + "selected_services": ["api", "worker"], + "tags": ["api", "docs"], + } + + +def test_data_workflow_deep_merges_mapping_values() -> None: + """DataWorkflow should expose deep merge without ad hoc lambda steps.""" + workflow = DataWorkflow.from_value({"service": {"name": "api"}, "ports": [8080]}).merge( + {"service": {"debug": True}, "ports": [8081]}, + name="merge-env", + ) + result = workflow.result() + + assert workflow.steps == ("value", "merge-env") + assert isinstance(workflow.value, ExtendedDict) + assert result.as_builtin() == { + "service": {"name": "api", "debug": True}, + "ports": [8080, 8081], + } + + +def test_data_workflow_merge_file_reads_and_merges_layer(tmp_path: Path) -> None: + """File-backed merge should use the same decoded DataFile boundary as reads.""" + write_file("base.yaml", {"service": {"name": "api"}, "ports": [8080]}, tld=tmp_path) + write_file("env.yaml", {"service": {"debug": "true"}, "ports": [8081]}, tld=tmp_path) + + workflow = DataWorkflow.from_file("base.yaml", tld=tmp_path).merge_file("env.yaml", tld=tmp_path) + result = workflow.transform("reconstruct").result() + + assert workflow.steps == ("read:base.yaml", "merge:env.yaml") + assert result.steps == ("read:base.yaml", "merge:env.yaml", "transform:reconstruct") + assert result.as_builtin() == { + "service": {"name": "api", "debug": True}, + "ports": [8080, 8081], + } + + +def test_data_workflow_merge_requires_mapping_values() -> None: + """Merge calls should fail loudly when no layer is provided.""" + with pytest.raises(ValueError, match=r"DataWorkflow\.merge requires at least one mapping"): + DataWorkflow.from_value({"service": "api"}).merge() + + +def test_data_workflow_merge_reports_shape_mismatch() -> None: + """Deep merge should fail when the current workflow value is not mapping-shaped.""" + with pytest.raises(TypeError, match="merge is not available for ExtendedList"): + DataWorkflow.from_value(["api"]).merge({"service": "api"}) + + +def test_data_workflow_applies_shared_named_transforms() -> None: + """DataWorkflow exposes common Tier 2 transforms without ad hoc lambdas.""" + raw_payload = { + "HTTPResponseCode": "200", + "SelectedServices": ["api", "api", "worker"], + "EmptyValue": "", + } + + workflow = DataWorkflow.from_value(raw_payload).transform( + "reconstruct", + "unhump", + "deduplicate", + "compact", + ) + result = workflow.result() + + assert workflow.steps == ( + "value", + "transform:reconstruct", + "transform:unhump", + "transform:deduplicate", + "transform:compact", + ) + assert result.as_builtin() == { + "http_response_code": 200, + "selected_services": ["api", "worker"], + } + + +def test_data_workflow_reconstruct_transform_handles_scalars() -> None: + """Named reconstruct should use the scalar string primitive when needed.""" + result = DataWorkflow.from_value("200").transform("reconstruct").result() + + assert result.value == 200 + + +def test_data_transform_action_reports_unknown_steps() -> None: + """Unknown named transforms should fail at the workflow boundary.""" + with pytest.raises(ValueError, match="unknown data transform 'missing'"): + data_transform_action("missing") + + +def test_data_workflow_transform_requires_steps() -> None: + """Transform calls should not silently preserve the old workflow value.""" + with pytest.raises(ValueError, match=r"DataWorkflow\.transform requires at least one step"): + DataWorkflow.from_value({"service": "api"}).transform() + + +def test_data_workflow_transform_reports_shape_mismatch() -> None: + """Shape-specific named transforms should fail when applied to incompatible data.""" + with pytest.raises(TypeError, match="transform 'unhump' is not available for ExtendedList"): + DataWorkflow.from_value(["api"]).transform("unhump") + + +def test_list_data_transform_steps_is_sorted_catalog() -> None: + """The transform catalog should be deterministic for CLIs and docs.""" + steps = list_data_transform_steps() + + assert steps == tuple(sorted(steps)) + assert {"compact", "reconstruct", "to-snake-case", "unhump"} <= set(steps) + + +def test_data_workflow_starts_from_data_file_artifact() -> None: + """DataFile artifacts can start named workflows without manual .data plumbing.""" + artifact = DataFile.decode('{"service": {"name": "api"}}', suffix="json", metadata={"status_code": 200}) + + workflow = artifact.workflow().then(("project-name", lambda data: {"name": data["service"]["name"]})) + result = workflow.result() + + assert workflow.steps == ("data_file:memory", "project-name") + assert isinstance(workflow.value, ExtendedDict) + assert workflow.metadata["status_code"] == 200 + assert result.value["name"].upper_first() == "Api" + assert result.metadata["status_code"] == 200 + assert result.metadata["source"] == "memory" + + +def test_data_workflow_from_data_file_can_return_builtin_state() -> None: + """DataFile-to-workflow composition can explicitly lower to plain Python values.""" + artifact = DataFile.decode('{"service": {"name": "api"}}', suffix="json") + + workflow = DataWorkflow.from_data_file(artifact, as_extended=False) + + assert workflow.steps == ("data_file:memory",) + assert isinstance(workflow.value, dict) + assert not isinstance(workflow.value, ExtendedDict) + assert workflow.metadata["encoding"] == "json" + + +def test_data_workflow_metadata_survives_state_transitions(tmp_path: Path) -> None: + """Workflow metadata should stay promoted through transforms, lowering, and writes.""" + write_file("config/service.json", {"service": {"name": "api"}}, tld=tmp_path) + + workflow = ( + DataWorkflow.from_file("config/service.json", tld=tmp_path) + .then(("project", lambda data: {"name": data["service"]["name"]})) + .as_builtin() + .as_extended() + ) + result = workflow.write("build/service.json", tld=tmp_path) + + assert workflow.metadata["source"] == "config/service.json" + assert workflow.metadata["encoding"] == "json" + assert workflow.metadata["path"] == str((tmp_path / "config" / "service.json").resolve()) + assert result.metadata == workflow.metadata + assert result.output_path == tmp_path / "build" / "service.json" + + +def test_workflow_metadata_views_are_detached() -> None: + """Workflow and result metadata accessors should not expose mutable internals.""" + workflow = DataWorkflow.from_value({"service": "api"}, metadata={"source": {"name": "payload"}}) + result = workflow.result() + + workflow_metadata = workflow.metadata + result_metadata = result.metadata + workflow_metadata["source"]["name"] = "mutated" + result_metadata["source"]["name"] = "also-mutated" + + assert workflow.metadata["source"]["name"] == "payload" + assert result.metadata["source"]["name"] == "payload" + + +def test_data_workflow_preserves_extended_policy_after_file_decode(tmp_path: Path) -> None: + """Decoded workflows keep promoting plain transform outputs by default.""" + write_file("config/service.json", {"service": {"name": "api"}}, tld=tmp_path) + + result = ( + DataWorkflow.from_file("config/service.json", tld=tmp_path) + .then(("project", lambda _data: {"name": "api"})) + .result() + ) + + assert isinstance(result.value, ExtendedDict) + assert result.value["name"].upper_first() == "Api" + + +def test_workflow_step_can_be_reused() -> None: + """WorkflowStep gives reusable transforms first-class names.""" + select_service_name = WorkflowStep("select-service-name", lambda data: data["service"]["name"].upper_first()) + + result = DataWorkflow.decode('{"service": {"name": "api"}}', suffix="json").then(select_service_name).result() + + assert result.steps == ("decode:json", "select-service-name") + assert result.value == "Api" + + +def test_data_workflow_can_lower_and_promote_values() -> None: + """Workflow states can move between Tier 2 containers and built-ins explicitly.""" + workflow = DataWorkflow.from_value({"service": {"name": "api"}}) + builtin = workflow.as_builtin() + extended = builtin.as_extended() + + assert isinstance(workflow.value, ExtendedDict) + assert isinstance(builtin.value, dict) + assert not isinstance(builtin.value, ExtendedDict) + assert isinstance(extended.value, ExtendedDict) + assert extended.value["service"]["name"].upper_first() == "Api" + + +def test_workflow_result_extended_view_is_detached() -> None: + """WorkflowResult accessors expose promoted data without sharing mutable state.""" + result = DataWorkflow.from_value({"service": {"name": "api"}}).result() + + promoted = result.as_extended() + promoted["service"]["name"] = "worker" + + assert isinstance(promoted, ExtendedDict) + assert isinstance(result.value, ExtendedDict) + assert result.value["service"]["name"] == "api" + assert result.as_extended()["service"]["name"].upper_first() == "Api" + + +def test_workflow_result_exports_from_completed_value() -> None: + """Completed workflow results can be exported without leaving the result boundary.""" + result = DataWorkflow.from_value( + {"launched": datetime.date(2026, 6, 10), "service": {"name": "api"}}, + ).result() + + export_safe = result.to_export_safe() + wrapped = result.wrap_for_export(allow_encoding="json") + + assert export_safe == {"launched": "2026-06-10", "service": {"name": "api"}} + assert json.loads(wrapped) == {"launched": "2026-06-10", "service": {"name": "api"}} + + +def test_data_workflow_preserves_tuples_until_serialization(tmp_path: Path) -> None: + """Workflow values keep tuple shape in memory and serialize to JSON arrays at the edge.""" + workflow = DataWorkflow.from_value({"aliases": ("api", "gateway")}) + + assert isinstance(workflow.value["aliases"], ExtendedTuple) + assert workflow.result().as_builtin() == {"aliases": ("api", "gateway")} + + result = workflow.write("build/aliases.json", tld=tmp_path) + + assert read_data_file(result.output_path) == {"aliases": ["api", "gateway"]} + + +def test_data_workflow_missing_file_fails_loudly(tmp_path: Path) -> None: + """Missing workflow inputs are hard failures, not placeholder results.""" + with pytest.raises(FileNotFoundError): + DataWorkflow.from_file("config/missing.yaml", tld=tmp_path) + + +def test_data_workflow_empty_write_fails_loudly(tmp_path: Path) -> None: + """Empty workflow outputs require an explicit opt-in.""" + with pytest.raises(ValueError, match="Workflow output was empty"): + DataWorkflow.from_value(None).write("build/empty.json", tld=tmp_path) def test_layered_config_workflow_round_trip(tmp_path: Path) -> None: @@ -36,14 +359,16 @@ def test_layered_config_workflow_round_trip(tmp_path: Path) -> None: write_file("config/base.yaml", base_config, tld=tmp_path) write_file("config/dev.yaml", env_config, tld=tmp_path) - base_data = decode_file(read_file("config/base.yaml", tld=tmp_path), file_path="config/base.yaml") - env_data = decode_file(read_file("config/dev.yaml", tld=tmp_path), file_path="config/dev.yaml") - merged = deep_merge(base_data, env_data) + base_data = read_data_file("config/base.yaml", tld=tmp_path) + env_data = read_data_file("config/dev.yaml", tld=tmp_path) + merged = base_data.deep_merge(env_data) output_path = write_file("build/config.yaml", merged, tld=tmp_path) + assert isinstance(base_data, ExtendedDict) + assert isinstance(merged, ExtendedDict) assert output_path == tmp_path / "build" / "config.yaml" - assert decode_file(read_file(output_path), file_path=output_path) == { + assert read_data_file(output_path) == { "service": {"name": "api", "debug": True}, "ports": [8080, 8081], "features": {"auth": True, "metrics": True}, @@ -74,18 +399,45 @@ def test_terraform_handoff_workflow_round_trip() -> None: def test_api_payload_normalization_workflow_round_trip(tmp_path: Path) -> None: """Compose list, map, string, and file helpers into a normalized payload flow.""" - payload = { + payload = ExtendedDict( + { + "HTTPResponseCode": 200, + "SelectedServices": ExtendedList(["api", "worker", "db"]).filter_values(denylist=["db"]), + "Tags": ["api", "api", "docs"], + } + ) + + normalized = payload.deduplicate().unhump() + + output_path = write_file("build/payload.json", normalized, tld=tmp_path) + + assert output_path == tmp_path / "build" / "payload.json" + assert isinstance(normalized, ExtendedDict) + assert read_data_file(output_path) == { + "http_response_code": 200, + "selected_services": ["api", "worker"], + "tags": ["api", "docs"], + } + + +def test_api_payload_factory_workflow_round_trip(tmp_path: Path) -> None: + """Promote decoded API payloads into containers before normalization.""" + raw_payload = { "HTTPResponseCode": 200, - "SelectedServices": filter_list(["api", "worker", "db"], denylist=["db"]), + "SelectedServices": ExtendedList(["api", "worker", "db"]).filter_values(denylist=["db"]), "Tags": ["api", "api", "docs"], } - normalized = {to_snake_case(key): value for key, value in deduplicate_map(payload).items()} + raw_path = write_file("build/raw-payload.json", raw_payload, tld=tmp_path) + decoded = read_data_file(raw_path) + normalized = decoded.deduplicate().unhump() output_path = write_file("build/payload.json", normalized, tld=tmp_path) assert output_path == tmp_path / "build" / "payload.json" - assert decode_file(read_file(output_path), file_path=output_path) == { + assert isinstance(decoded, ExtendedDict) + assert isinstance(normalized, ExtendedDict) + assert read_data_file(output_path) == { "http_response_code": 200, "selected_services": ["api", "worker"], "tags": ["api", "docs"], @@ -100,9 +452,10 @@ def test_yaml_native_workflow_round_trip(tmp_path: Path) -> None: } output_path = write_file("template.yaml", template, tld=tmp_path) - decoded = decode_file(read_file(output_path), file_path=output_path) + decoded = read_data_file(output_path) assert output_path == tmp_path / "template.yaml" + assert isinstance(decoded, ExtendedDict) assert isinstance(decoded["bucket_name"], YamlTagged) assert decoded["bucket_name"].tag == "!Ref" assert decoded["bucket_name"].__wrapped__ == "BucketName" diff --git a/tests/core/test_yaml_utils.py b/tests/core/test_yaml_utils.py index 4a45679..66b2e56 100644 --- a/tests/core/test_yaml_utils.py +++ b/tests/core/test_yaml_utils.py @@ -19,11 +19,12 @@ from types import SimpleNamespace import pytest -import yaml from yaml import MappingNode, ScalarNode, SequenceNode -from extended_data.yaml_utils import ( +from extended_data.containers import ExtendedDict +from extended_data.primitives.formats.errors import DataDecodeError +from extended_data.primitives.formats.yaml import ( LiteralScalarString, YamlPairs, YamlTagged, @@ -92,6 +93,17 @@ def test_encode_yaml(simple_yaml_fixture: str) -> None: assert result_data == expected_data +@pytest.mark.parametrize("use_data_attribute", [False, True]) +def test_encode_yaml_lowers_extended_containers(use_data_attribute: bool) -> None: + """Encode Tier 2 containers while preserving YAML-compatible built-ins.""" + payload = ExtendedDict({"status": "ok", "items": ["one"]}) + raw_data = payload.data if use_data_attribute else payload + + result = encode_yaml(raw_data) + + assert decode_yaml(result) == {"status": "ok", "items": ["one"]} + + def test_yaml_construct_undefined() -> None: """Tests decoding of YAML data with a custom tag. @@ -155,11 +167,22 @@ def test_decode_yaml_bytes_success(simple_yaml_fixture: str) -> None: def test_decode_yaml_invalid_bytes() -> None: - """Raise a YAMLError when bytes cannot be decoded.""" - with pytest.raises(yaml.YAMLError, match="Failed to decode bytes to string"): + """Raise a sanitized decode error when bytes cannot be decoded.""" + with pytest.raises(DataDecodeError, match="input bytes are not valid UTF-8"): decode_yaml(b"\x80") +def test_decode_yaml_invalid_input_does_not_echo_payload() -> None: + """Invalid YAML messages do not include source snippets.""" + with pytest.raises(DataDecodeError) as exc_info: + decode_yaml("token: [super-secret") + + message = str(exc_info.value) + assert "Failed to decode YAML data" in message + assert "line 1" in message + assert "super-secret" not in message + + @pytest.mark.parametrize( ("node", "loader_method", "constructed_value"), [ diff --git a/tests/core/transformations/numbers/test_notation.py b/tests/core/transformations/numbers/test_notation.py index da6b646..e7eb2ea 100644 --- a/tests/core/transformations/numbers/test_notation.py +++ b/tests/core/transformations/numbers/test_notation.py @@ -4,7 +4,7 @@ import pytest -from extended_data.transformations.numbers.notation import ( +from extended_data.primitives.transformations.numbers.notation import ( from_fraction, from_ordinal, from_roman, diff --git a/tests/core/transformations/numbers/test_words.py b/tests/core/transformations/numbers/test_words.py index 04947c7..6f7c7d0 100644 --- a/tests/core/transformations/numbers/test_words.py +++ b/tests/core/transformations/numbers/test_words.py @@ -4,8 +4,8 @@ import pytest -from extended_data.transformations.numbers import words as words_module -from extended_data.transformations.numbers.words import ( +from extended_data.primitives.transformations.numbers import words as words_module +from extended_data.primitives.transformations.numbers.words import ( fraction_to_words, number_to_words, ordinal_to_words, diff --git a/tests/core/transformations/strings/test_inflection.py b/tests/core/transformations/strings/test_inflection.py index 1fe541e..cf331d7 100644 --- a/tests/core/transformations/strings/test_inflection.py +++ b/tests/core/transformations/strings/test_inflection.py @@ -4,7 +4,7 @@ import pytest -from extended_data.transformations.strings.inflection import ( +from extended_data.primitives.transformations.strings.inflection import ( camelize, humanize, ordinalize, diff --git a/tests/examples/test_safe_examples.py b/tests/examples/test_safe_examples.py new file mode 100644 index 0000000..5019ccb --- /dev/null +++ b/tests/examples/test_safe_examples.py @@ -0,0 +1,251 @@ +"""Smoke tests for examples that do not require live vendor credentials.""" + +from __future__ import annotations + +import ast +import importlib.util +import os +import py_compile +import re +import subprocess +import sys + +from pathlib import Path + +import pytest + +from extended_data import primitives + + +REPO_ROOT = Path(__file__).resolve().parents[2] +SAFE_EXAMPLES = [ + "examples/core/basic_usage.py", + "examples/core/composed_workflows.py", + "examples/core/file_operations.py", + "examples/core/serialization.py", + "examples/core/string_transformations.py", + "examples/inputs/basic_usage.py", + "examples/inputs/decorator_api.py", + "examples/inputs/encoding_decoding.py", + "examples/logging/basic_logging.py", + "examples/logging/exit_run_formatting.py", + "examples/logging/markers_and_storage.py", + "examples/logging/verbosity_control.py", +] +CONNECTOR_EXAMPLES = [ + "examples/connectors/basic_aws.py", + "examples/connectors/basic_google.py", + "examples/connectors/basic_meshy.py", + "examples/connectors/basic_secrets.py", + "examples/connectors/langchain_tools.py", + "examples/connectors/mcp_server.py", +] +ALL_EXAMPLES = SAFE_EXAMPLES + CONNECTOR_EXAMPLES +STALE_EXAMPLE_COMMANDS = ( + "python examples/mcp_server.py", + "python -m examples.decorator_api", + "python -m examples.encoding_decoding", +) +FUNCTION_FIRST_BASIC_USAGE_HELPERS = ( + "deep_merge", + "filter_list", + "filter_map", + "flatten_list", + "flatten_map", + "sanitize_key", + "truncate", +) +ROOT_DISALLOWED_TIER1_IMPORTS = tuple(sorted(primitives.__all__)) +PYTHON_MARKDOWN_BLOCK_RE = re.compile(r"```python\n(?P.*?)\n```", re.DOTALL) +SENSITIVE_IDENTIFIER_RE = re.compile(r"(api_?key|secret|token|password|authorization)", re.IGNORECASE) + + +def _readme_usage_snippet() -> str: + readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8") + usage_section = readme.split("## Usage", 1)[1].split("## Package Shape", 1)[0] + match = re.search(r"```python\n(?P.*?)\n```", usage_section, re.DOTALL) + assert match is not None + return match.group("code") + + +def test_example_inventory_is_complete() -> None: + """Every Python example should be explicitly classified for test coverage.""" + discovered = sorted( + str(path.relative_to(REPO_ROOT)) + for path in (REPO_ROOT / "examples").rglob("*.py") + if path.name != "__init__.py" + ) + + assert sorted(ALL_EXAMPLES) == discovered + + +@pytest.mark.parametrize("example_path", SAFE_EXAMPLES) +def test_safe_example_runs(example_path: str, tmp_path: Path) -> None: + """Keep runnable examples aligned with the installed package surface.""" + env = os.environ.copy() + env.pop("OVERRIDE_STDIN", None) + + result = subprocess.run( + [sys.executable, str(REPO_ROOT / example_path)], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + timeout=15, + check=False, + ) + + assert result.returncode == 0, f"{example_path} failed\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + + +def test_readme_usage_snippet_runs(tmp_path: Path) -> None: + """Keep the primary README example executable as a public contract.""" + env = os.environ.copy() + env.pop("OVERRIDE_STDIN", None) + + result = subprocess.run( + [sys.executable, "-c", _readme_usage_snippet()], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + timeout=15, + check=False, + ) + + assert result.returncode == 0, f"README usage snippet failed\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + + +def test_markdown_python_snippets_compile() -> None: + """Documentation snippets may be conceptual, but they should remain valid Python.""" + markdown_paths = [REPO_ROOT / "README.md", *(REPO_ROOT / "docs").rglob("*.md")] + offenders: list[str] = [] + + for path in sorted(markdown_paths): + text = path.read_text(encoding="utf-8") + for index, match in enumerate(PYTHON_MARKDOWN_BLOCK_RE.finditer(text), start=1): + code = match.group("code") + try: + compile(code, f"{path.relative_to(REPO_ROOT)}#python-block-{index}", "exec") + except SyntaxError as exc: + offenders.append(f"{path.relative_to(REPO_ROOT)} block {index}: {exc}") + + assert offenders == [] + + +def test_examples_do_not_document_stale_command_paths() -> None: + """Example command snippets should point at the current directory layout.""" + offenders: list[str] = [] + + for example_path in ALL_EXAMPLES: + text = (REPO_ROOT / example_path).read_text(encoding="utf-8") + for command in STALE_EXAMPLE_COMMANDS: + if command in text: + offenders.append(f"{example_path}: {command}") + + assert offenders == [] + + +def test_basic_core_example_uses_container_first_operations() -> None: + """The basic example should lead with Tier 2 methods for list/map/string workflows.""" + text = (REPO_ROOT / "examples/core/basic_usage.py").read_text(encoding="utf-8") + import_block = text.split("from extended_data import (", maxsplit=1)[1].split(")", maxsplit=1)[0] + + offenders = [name for name in FUNCTION_FIRST_BASIC_USAGE_HELPERS if name in import_block] + + assert offenders == [] + + +def test_composed_workflow_example_uses_named_transforms() -> None: + """The workflow example should exercise public merge and transform APIs.""" + text = (REPO_ROOT / "examples/core/composed_workflows.py").read_text(encoding="utf-8") + + assert ".merge(" in text + assert ".transform(" in text + assert "list_data_transform_steps" in text + + +def test_examples_do_not_import_tier1_utilities_from_root() -> None: + """Examples should import pure Tier 1 utilities from extended_data.primitives.""" + offenders: list[str] = [] + + for example_path in ALL_EXAMPLES: + text = (REPO_ROOT / example_path).read_text(encoding="utf-8") + tree = ast.parse(text) + for node in ast.walk(tree): + if not isinstance(node, ast.ImportFrom) or node.module != "extended_data": + continue + + imported_names = {alias.name for alias in node.names} + disallowed = sorted(imported_names.intersection(ROOT_DISALLOWED_TIER1_IMPORTS)) + if disallowed: + offenders.append(f"{example_path}: {', '.join(disallowed)}") + + assert offenders == [] + + +def test_secrets_example_does_not_print_raw_sync_results() -> None: + """SecretSync output can include secret values and should not be echoed.""" + text = (REPO_ROOT / "examples/connectors/basic_secrets.py").read_text(encoding="utf-8") + raw_result_fields = [ + "error_message", + "secrets_processed", + "secrets_added", + "secrets_modified", + "secrets_removed", + "secrets_unchanged", + "diff_output", + ] + + for field in raw_result_fields: + assert f'print(result["{field}"])' not in text + assert f"print(result['{field}'])" not in text + + +def _is_sensitive_identifier(node: ast.AST) -> bool: + if isinstance(node, ast.Name): + return bool(SENSITIVE_IDENTIFIER_RE.search(node.id)) + if isinstance(node, ast.Attribute): + return bool(SENSITIVE_IDENTIFIER_RE.search(node.attr)) + return False + + +def _expression_contains_sensitive_identifier(node: ast.AST) -> bool: + return any(_is_sensitive_identifier(child) for child in ast.walk(node)) + + +def test_examples_do_not_echo_partial_sensitive_values() -> None: + """Examples should not teach printing, slicing, or returning credential fragments.""" + offenders: list[str] = [] + + for example_path in ALL_EXAMPLES: + tree = ast.parse((REPO_ROOT / example_path).read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.Subscript) and _is_sensitive_identifier(node.value): + offenders.append(f"{example_path}:{node.lineno}: slices or indexes a sensitive value") + if isinstance(node, ast.FormattedValue) and _expression_contains_sensitive_identifier(node.value): + offenders.append(f"{example_path}:{node.lineno}: interpolates a sensitive value") + if isinstance(node, ast.Return) and node.value is not None and _is_sensitive_identifier(node.value): + offenders.append(f"{example_path}:{node.lineno}: returns a sensitive value directly") + + assert offenders == [] + + +@pytest.mark.parametrize("example_path", ALL_EXAMPLES) +def test_example_compiles(example_path: str, tmp_path: Path) -> None: + """Every example should at least remain syntactically valid.""" + py_compile.compile(str(REPO_ROOT / example_path), cfile=str(tmp_path / "example.pyc"), doraise=True) + + +@pytest.mark.parametrize("example_path", CONNECTOR_EXAMPLES) +def test_connector_example_imports_without_live_credentials(example_path: str) -> None: + """Credential-gated connector examples should keep import-time side effects out.""" + module_path = REPO_ROOT / example_path + module_name = example_path.replace("/", "_").removesuffix(".py") + spec = importlib.util.spec_from_file_location(module_name, module_path) + assert spec is not None + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + + assert callable(module.main) diff --git a/tests/inputs/test_decorators.py b/tests/inputs/test_decorators.py index 91534e3..d973edc 100644 --- a/tests/inputs/test_decorators.py +++ b/tests/inputs/test_decorators.py @@ -4,6 +4,7 @@ import pytest +from extended_data.containers import ExtendedDict, ExtendedString from extended_data.inputs import directed_inputs, input_config @@ -22,6 +23,22 @@ def secure_call(self, api_key: str) -> str: def parse_config(self, config: dict[str, str]) -> dict[str, str]: return config + @input_config("extended_config", decode_from_json=True, as_extended=True) + def parse_extended_config(self, extended_config: ExtendedDict) -> ExtendedDict: + return extended_config + + @input_config("raw_config", as_extended=True) + def parse_raw_extended_config(self, raw_config: ExtendedDict) -> ExtendedDict: + return raw_config + + @input_config("optional_value", allow_none=True) + def optional_plain_value(self, optional_value: str | None = "method-default") -> str | None: + return optional_value + + @input_config("required_value", required=True, allow_none=True) + def required_plain_value(self, required_value: str | None = "method-default") -> str | None: + return required_value + def greet(self, prefix: str = "hello") -> str: return prefix @@ -52,6 +69,35 @@ def test_decode_from_json_input_config() -> None: assert service.parse_config() == {"enabled": True} +def test_decode_from_json_input_config_can_return_extended_containers() -> None: + service = ExampleService(_input_provider_config={"inputs": {"extended_config": '{"name": "api"}'}}) + parsed = service.parse_extended_config() + + assert isinstance(parsed, ExtendedDict) + assert isinstance(parsed["name"], ExtendedString) + + +def test_plain_input_config_can_return_extended_containers() -> None: + service = ExampleService(_input_provider_config={"inputs": {"raw_config": {"name": "api"}}}) + parsed = service.parse_raw_extended_config() + + assert isinstance(parsed, ExtendedDict) + assert isinstance(parsed["name"], ExtendedString) + + +def test_plain_input_config_honors_explicit_none() -> None: + service = ExampleService(_input_provider_config={"inputs": {"optional_value": None}}) + + assert service.optional_plain_value() is None + + +def test_plain_input_config_required_none_still_raises() -> None: + service = ExampleService(_input_provider_config={"inputs": {"required_value": None}}) + + with pytest.raises(RuntimeError, match="Required input required_value not passed"): + service.required_plain_value() + + def test_method_default_used_when_input_missing() -> None: service = ExampleService(_input_provider_config={"inputs": {"domain": "acme.io"}}) assert service.greet() == "hello" @@ -69,3 +115,11 @@ def test_decorator_exposes_input_provider_property() -> None: assert service.input_provider.get_input("domain") == "override.io" assert not hasattr(service, "directed_inputs") + + +def test_decorator_metadata_uses_extended_options() -> None: + metadata = ExampleService.__input_provider_metadata__ + + assert isinstance(metadata.options, ExtendedDict) + assert isinstance(metadata.options["inputs"], ExtendedDict) + assert isinstance(metadata.options["inputs"]["domain"], ExtendedString) diff --git a/tests/inputs/test_main.py b/tests/inputs/test_main.py index 6d351e6..5e71efa 100644 --- a/tests/inputs/test_main.py +++ b/tests/inputs/test_main.py @@ -28,6 +28,7 @@ from __future__ import annotations +import base64 import json import os @@ -36,6 +37,8 @@ import pytest from extended_data import base64_encode +from extended_data.containers import ExtendedDict, ExtendedString +from extended_data.inputs import __main__ as inputs_module from extended_data.inputs.__main__ import InputProvider @@ -94,6 +97,24 @@ def test_init_with_stdin(monkeypatch): assert dic.inputs["stdin_key"] == "stdin_value" +@pytest.mark.usefixtures("_env_setup") +def test_init_with_stdin_decodes_through_data_boundary(monkeypatch): + """Stdin JSON should use the shared data decoder before merging inputs.""" + + def fake_decode_file(data, *, suffix=None, as_extended=True): + assert data == '{"stdin_key": "stdin_value"}' + assert suffix == "json" + assert as_extended is False + return {"stdin_key": "stdin_value"} + + monkeypatch.setattr("sys.stdin.read", lambda: '{"stdin_key": "stdin_value"}') + monkeypatch.setattr(inputs_module, "decode_file", fake_decode_file) + + dic = InputProvider(from_stdin=True) + + assert dic.inputs["stdin_key"] == "stdin_value" + + def test_get_input_with_default(): """Test retrieving an input with a default value. @@ -101,20 +122,60 @@ def test_get_input_with_default(): returning a default value if the key is not found. """ dic = InputProvider(inputs={"key1": "value1"}) + assert isinstance(dic.inputs, ExtendedDict) + assert isinstance(dic.inputs["key1"], ExtendedString) assert dic.get_input("key1", default="default_value") == "value1" + assert isinstance(dic.get_input("key1"), str) assert dic.get_input("key2", default="default_value") == "default_value" +def test_get_input_uses_exact_keys(): + """InputProvider now uses the package's exact-key ExtendedDict surface.""" + dic = InputProvider(inputs={"API_KEY": "secret"}, from_environment=False) + + assert dic.get_input("api_key", default="fallback") == "fallback" + assert dic.get_input("API_KEY") == "secret" + + +def test_get_input_can_return_extended_containers(): + """Plain input retrieval can opt into the Tier 2 container layer.""" + dic = InputProvider(inputs={"config": {"service": "api"}, "name": "gateway"}, from_environment=False) + + config = dic.get_input("config", as_extended=True) + name = dic.get_input("name", as_extended=True) + + assert isinstance(config, ExtendedDict) + assert isinstance(config["service"], ExtendedString) + assert isinstance(name, ExtendedString) + assert name.upper_first() == "Gateway" + + def test_get_input_required(): """Test retrieving a required input. This test verifies that the InputProvider raises an error if a required input is not provided. """ - dic = InputProvider(inputs={"key1": "value1"}) - with pytest.raises(RuntimeError, match="Required input key2 not passed"): + dic = InputProvider(inputs={"key1": "value1", "API_TOKEN": "super-secret"}, from_environment=False) + with pytest.raises(RuntimeError, match="Required input key2 not passed") as exc_info: dic.get_input("key2", required=True) + message = str(exc_info.value) + assert "key1" in message + assert "API_TOKEN" in message + assert "value1" not in message + assert "super-secret" not in message + + +def test_init_with_invalid_stdin_does_not_echo_payload(monkeypatch): + """Invalid stdin diagnostics do not expose raw stdin content.""" + monkeypatch.setattr("sys.stdin.read", lambda: '{"API_TOKEN": "super-secret"') + + with pytest.raises(RuntimeError, match="Failed to decode stdin as JSON") as exc_info: + InputProvider(from_stdin=True) + + assert "super-secret" not in str(exc_info.value) + def test_get_input_boolean(): """Test retrieving and converting a boolean input. @@ -132,6 +193,16 @@ def test_get_input_boolean_existing_bool(): assert dic.get_input("bool_key", is_bool=True) is False +def test_get_input_boolean_conversion_errors_do_not_echo_values(): + """Boolean conversion diagnostics identify the key without exposing the value.""" + dic = InputProvider(inputs={"bool_key": "super-secret"}) + + with pytest.raises(RuntimeError, match="Input bool_key cannot be converted to boolean") as exc_info: + dic.get_input("bool_key", is_bool=True) + + assert "super-secret" not in str(exc_info.value) + + def test_get_input_integer(): """Test retrieving and converting an integer input. @@ -143,6 +214,16 @@ def test_get_input_integer(): assert dic.get_input("int_key", is_integer=True) == integer_test_value +def test_get_input_conversion_errors_do_not_echo_values(): + """Type conversion diagnostics identify the key without exposing the value.""" + dic = InputProvider(inputs={"int_key": "super-secret"}) + + with pytest.raises(RuntimeError, match="Input int_key cannot be converted to integer") as exc_info: + dic.get_input("int_key", is_integer=True) + + assert "super-secret" not in str(exc_info.value) + + def test_decode_input_json(): """Test decoding an input from JSON format. @@ -165,6 +246,36 @@ def test_decode_input_yaml(): assert decoded == {"name": "test"} +@pytest.mark.parametrize( + ("input_key", "input_value", "decode_kwargs", "expected_suffix"), + [ + ("json_key", '{"name": "test"}', {"decode_from_json": True}, "json"), + ("yaml_key", "name: test", {"decode_from_yaml": True}, "yaml"), + ], +) +def test_decode_input_uses_data_boundary( + monkeypatch, + input_key: str, + input_value: str, + decode_kwargs: dict[str, bool], + expected_suffix: str, +) -> None: + """Structured input decoding should use the shared file/data decoder.""" + + def fake_decode_file(data, *, suffix=None, as_extended=True): + assert data == input_value + assert suffix == expected_suffix + assert as_extended is False + return {"name": "test"} + + monkeypatch.setattr(inputs_module, "decode_file", fake_decode_file) + dic = InputProvider(inputs={input_key: input_value}, from_environment=False) + + decoded = dic.decode_input(input_key, **decode_kwargs) + + assert decoded == {"name": "test"} + + def test_decode_input_base64(): """Test decoding an input from Base64 format. @@ -186,6 +297,135 @@ def test_decode_input_base64_from_bytes(): assert decoded == {"name": "test"} +def test_decode_input_json_can_return_extended_containers(): + """Decoded input payloads can opt into the Tier 2 container layer.""" + dic = InputProvider(inputs={"json_key": '{"name": "test"}'}) + decoded = dic.decode_input("json_key", decode_from_json=True, as_extended=True) + + assert isinstance(decoded, ExtendedDict) + assert isinstance(decoded["name"], ExtendedString) + assert decoded["name"].upper_first() == "Test" + + +def test_decode_input_extended_containers_can_use_tier2_export_methods(): + """Decoded input payloads can stay inside the integrated container surface.""" + dic = InputProvider(inputs={"json_key": '{"enabled": "true", "retries": "5", "service": {"name": "api"}}'}) + + decoded = dic.decode_input("json_key", decode_from_json=True, as_extended=True) + reconstructed = decoded.reconstruct_special_types() + + assert isinstance(decoded, ExtendedDict) + assert decoded.to_export_safe() == {"enabled": "true", "retries": "5", "service": {"name": "api"}} + assert json.loads(decoded.wrap_for_export(allow_encoding="json")) == { + "enabled": "true", + "retries": "5", + "service": {"name": "api"}, + } + assert isinstance(reconstructed, ExtendedDict) + assert reconstructed["enabled"] is True + assert reconstructed["retries"] == 5 + assert reconstructed["service"]["name"].upper_first() == "Api" + + +def test_decode_input_decodes_present_value_that_equals_default(): + """Defaults should not mask present input values that happen to be equal.""" + raw_config = '{"name": "test"}' + dic = InputProvider(inputs={"json_key": raw_config}, from_environment=False) + missing = InputProvider(from_environment=False) + + decoded = dic.decode_input("json_key", default=raw_config, decode_from_json=True, as_extended=True) + + assert isinstance(decoded, ExtendedDict) + assert isinstance(decoded["name"], ExtendedString) + assert decoded["name"].upper_first() == "Test" + assert missing.decode_input("json_key", default=raw_config, decode_from_json=True) == raw_config + + +def test_decode_input_missing_default_can_return_extended_containers(): + """Missing decoded inputs promote fallback data when Tier 2 output is requested.""" + dic = InputProvider(from_environment=False) + + decoded = dic.decode_input( + "missing_key", + default={"enabled": "true", "service": {"name": "fallback"}}, + decode_from_json=True, + as_extended=True, + ) + + assert isinstance(decoded, ExtendedDict) + assert isinstance(decoded["service"], ExtendedDict) + assert decoded["service"]["name"].upper_first() == "Fallback" + assert decoded.reconstruct_special_types()["enabled"] is True + + +def test_decode_input_honors_explicit_none_values(): + """Present None inputs should obey allow_none instead of looking missing.""" + dic = InputProvider(inputs={"json_key": None}, from_environment=False) + missing = InputProvider(from_environment=False) + + assert dic.decode_input("json_key", default="fallback", decode_from_json=True, allow_none=True) is None + assert dic.decode_input("json_key", default="fallback", decode_from_json=True, allow_none=False) == "fallback" + assert missing.decode_input("json_key", default="fallback", decode_from_json=True, allow_none=True) == "fallback" + + +def test_decode_input_none_fallback_can_return_extended_containers(): + """Explicit None fallbacks use the same promotion rule when None is disallowed.""" + dic = InputProvider(inputs={"json_key": None}, from_environment=False) + + decoded = dic.decode_input( + "json_key", + default={"enabled": "false"}, + decode_from_json=True, + allow_none=False, + as_extended=True, + ) + + assert isinstance(decoded, ExtendedDict) + assert decoded.reconstruct_special_types()["enabled"] is False + + +def test_decode_input_required_empty_value_raises(): + """Required decode inputs still reject empty provided values.""" + dic = InputProvider(inputs={"json_key": ""}, from_environment=False) + + with pytest.raises(RuntimeError, match="Required input json_key not passed"): + dic.decode_input("json_key", decode_from_json=True, required=True) + + +def test_decode_input_errors_do_not_echo_values(): + """Decode diagnostics identify the input key without exposing raw values.""" + dic = InputProvider( + inputs={ + "json_key": '{"token": "super-secret"', + "yaml_key": "token: [super-secret", + "base64_key": "not valid base64!", + } + ) + + with pytest.raises(RuntimeError, match="Failed to decode input json_key from JSON") as json_exc: + dic.decode_input("json_key", decode_from_json=True) + with pytest.raises(RuntimeError, match="Failed to decode input yaml_key from YAML") as yaml_exc: + dic.decode_input("yaml_key", decode_from_yaml=True) + with pytest.raises(RuntimeError, match="Failed to decode input base64_key from Base64") as base64_exc: + dic.decode_input("base64_key", decode_from_base64=True) + + for exc_info in (json_exc, yaml_exc, base64_exc): + message = str(exc_info.value) + assert "super-secret" not in message + assert "not valid base64" not in message + + +def test_decode_input_base64_external_json_can_return_extended_containers(): + """Externally produced Base64 JSON should decode once and then be extended.""" + encoded_value = base64.b64encode(b'{"name": "test"}').decode("utf-8") + dic = InputProvider(inputs={"base64_key": encoded_value}) + decoded = dic.decode_input("base64_key", decode_from_base64=True, decode_from_json=True, as_extended=True) + + assert isinstance(decoded, ExtendedDict) + assert isinstance(decoded["name"], ExtendedString) + assert decoded["name"].upper_first() == "Test" + + def test_freeze_inputs(): """Test freezing inputs. @@ -194,7 +434,10 @@ def test_freeze_inputs(): """ dic = InputProvider(inputs={"key1": "value1"}) frozen_inputs = dic.freeze_inputs() + assert isinstance(frozen_inputs, ExtendedDict) assert frozen_inputs["key1"] == "value1" + assert isinstance(frozen_inputs["key1"], ExtendedString) + assert isinstance(dic.inputs, ExtendedDict) assert dic.inputs == {} @@ -207,10 +450,66 @@ def test_thaw_inputs(): dic = InputProvider(inputs={"key1": "value1"}) dic.freeze_inputs() dic.thaw_inputs() + assert isinstance(dic.inputs, ExtendedDict) assert dic.inputs["key1"] == "value1" + assert isinstance(dic.inputs["key1"], ExtendedString) + assert isinstance(dic.frozen_inputs, ExtendedDict) assert dic.frozen_inputs == {} +def test_snapshot_inputs_returns_detached_extended_copy(): + """Input snapshots are promoted copies, not mutable internal state.""" + dic = InputProvider(inputs={"service": {"name": "api"}}) + + snapshot = dic.snapshot_inputs() + snapshot["service"]["name"] = "worker" + + assert isinstance(snapshot, ExtendedDict) + assert isinstance(snapshot["service"], ExtendedDict) + assert isinstance(snapshot["service"]["name"], ExtendedString) + assert dic.inputs["service"]["name"] == "api" + assert dic.snapshot_inputs()["service"]["name"].upper_first() == "Api" + + +def test_snapshot_inputs_can_select_frozen_state(): + """Frozen input snapshots can be inspected without thawing state.""" + dic = InputProvider(inputs={"service": {"name": "api"}}, from_environment=False) + dic.freeze_inputs() + + frozen = dic.snapshot_inputs(frozen=True) + + assert isinstance(frozen, ExtendedDict) + assert isinstance(frozen["service"], ExtendedDict) + assert frozen["service"]["name"].upper_first() == "Api" + assert dic.inputs == {} + assert dic.frozen_inputs["service"]["name"] == "api" + + +def test_replace_inputs_promotes_values_and_clears_frozen_state_by_default(): + """Replacing inputs should be explicit and should not keep stale frozen state.""" + dic = InputProvider(inputs={"service": {"name": "api"}}, from_environment=False) + dic.freeze_inputs() + + replaced = dic.replace_inputs({"service": {"name": "worker"}}) + + assert isinstance(replaced, ExtendedDict) + assert isinstance(replaced["service"], ExtendedDict) + assert replaced["service"]["name"].upper_first() == "Worker" + assert dic.inputs["service"]["name"] == "worker" + assert dic.frozen_inputs == {} + + +def test_replace_inputs_can_preserve_frozen_state_when_requested(): + """Replacement can keep frozen inputs for explicit staged-state workflows.""" + dic = InputProvider(inputs={"service": {"name": "api"}}, from_environment=False) + dic.freeze_inputs() + + dic.replace_inputs({"region": "us-east-1"}, clear_frozen=False) + + assert dic.inputs["region"].upper_first() == "Us-east-1" + assert dic.snapshot_inputs(frozen=True)["service"]["name"].upper_first() == "Api" + + def test_shift_inputs(): """Test shifting between frozen and thawed inputs. @@ -219,6 +518,8 @@ def test_shift_inputs(): """ dic = InputProvider(inputs={"key1": "value1"}) dic.shift_inputs() + assert isinstance(dic.inputs, ExtendedDict) + assert isinstance(dic.frozen_inputs, ExtendedDict) assert dic.inputs == {} assert dic.frozen_inputs["key1"] == "value1" @@ -232,6 +533,8 @@ def test_merge_inputs_deep_merge(): dic = InputProvider(inputs={"nested": {"left": 1}}) merged = dic.merge_inputs({"nested": {"right": 2}}) + assert isinstance(merged, ExtendedDict) + assert isinstance(merged["nested"], ExtendedDict) assert merged["nested"] == {"left": 1, "right": 2} @@ -244,5 +547,6 @@ def test_environment_prefix_filter(monkeypatch): dic = InputProvider(from_environment=True, env_prefix="APP_", strip_env_prefix=True) assert dic.inputs["ALPHA"] == "alpha" + assert dic.inputs["ALPHA"].upper_first() == "Alpha" assert dic.inputs["BETA"] == "beta" assert "UNSCOPED" not in dic.inputs diff --git a/tests/logging/integration/test_lifecycle_logging.py b/tests/logging/integration/test_lifecycle_logging.py index 0b1d7a2..d891150 100644 --- a/tests/logging/integration/test_lifecycle_logging.py +++ b/tests/logging/integration/test_lifecycle_logging.py @@ -6,6 +6,7 @@ import pytest +from extended_data.containers import ExtendedDict, ExtendedSet from extended_data.logging import Logging @@ -59,6 +60,8 @@ def test_full_logging_lifecycle(temp_logger: Logging, tmp_path: Path) -> None: log_level="info", # type: ignore[arg-type] ) assert storage_result is not None + assert isinstance(temp_logger.stored_messages, ExtendedDict) + assert isinstance(temp_logger.stored_messages[storage_marker], ExtendedSet) assert storage_msg in temp_logger.stored_messages[storage_marker] # Verify file output exists at the location specified in fixture diff --git a/tests/logging/test_exit_run.py b/tests/logging/test_exit_run.py index d965f25..fa1699f 100644 --- a/tests/logging/test_exit_run.py +++ b/tests/logging/test_exit_run.py @@ -11,6 +11,8 @@ import pytest +import extended_data.logging.logging as logging_module + from extended_data.logging import ExitRunError, Logging @@ -85,13 +87,12 @@ def test_exit_run_none_results(self, logger: Logging, tmp_path: Path) -> None: output = logger.exit_run(None, exit_on_completion=False) assert output == {} - def test_exit_run_unhump_results(self, logger: Logging, tmp_path: Path) -> None: - """Test that exit_run converts camelCase to snake_case.""" + def test_exit_run_unhump_results_is_not_preserved(self, logger: Logging, tmp_path: Path) -> None: + """The clean major-version API should not keep the old shorthand flag.""" os.chdir(tmp_path) results = {"myKey": {"nestedKey": "value"}} - output = logger.exit_run(results, unhump_results=True, exit_on_completion=False) - assert "my_key" in output - assert "nested_key" in output["my_key"] + with pytest.raises(TypeError, match="unhump_results"): + logger.exit_run(results, unhump_results=True, exit_on_completion=False) def test_exit_run_key_transform_snake_case(self, logger: Logging, tmp_path: Path) -> None: """Test key_transform with snake_case string.""" @@ -259,6 +260,38 @@ def test_exit_run_sort_missing_field_raises(self, logger: Logging, tmp_path: Pat exit_on_completion=False, ) + def test_exit_run_formatting_errors_redact_result_snapshot(self, logger: Logging, tmp_path: Path) -> None: + """Formatting failures should report redacted diagnostics without chained traces.""" + os.chdir(tmp_path) + results = { + "a": { + "otherField": "value", + "password": "hunter2", + "headers": {"authorization": "Bearer raw_token"}, + } + } + + with ( + patch.object(logger.logger, "critical") as mock_critical, + pytest.raises(RuntimeError, match="formatting error") as exc_info, + ): + logger.exit_run( + results, + sort_by_field="missingField", + exit_on_completion=False, + ) + + mock_critical.assert_called_once() + logged_message = mock_critical.call_args.args[0] + assert mock_critical.call_args.kwargs == {} + assert exc_info.value.__cause__ is None + assert exc_info.value.__suppress_context__ is True + for raw_secret in ["hunter2", "raw_token"]: + assert raw_secret not in logged_message + assert raw_secret not in str(exc_info.value) + assert "[REDACTED]" in logged_message + assert "Traceback" not in logged_message + def test_exit_run_with_errors_raises(self, logger: Logging, tmp_path: Path) -> None: """Test that exit_run raises when error_list is not empty.""" os.chdir(tmp_path) @@ -280,12 +313,17 @@ def test_exit_run_writes_to_stdout_and_exits(self, logger: Logging, tmp_path: Pa with ( patch("sys.stdout.write") as mock_write, patch("sys.exit") as mock_exit, + patch( + "extended_data.logging.logging.wrap_raw_data_for_export", + wraps=logging_module.wrap_raw_data_for_export, + ) as mock_wrap_for_export, ): logger.exit_run(results) mock_write.assert_called_once() written = mock_write.call_args[0][0] assert json.loads(written) == results mock_exit.assert_called_once_with(0) + mock_wrap_for_export.assert_any_call(results, allow_encoding="json", default=str) def test_exit_run_wraps_in_key(self, logger: Logging, tmp_path: Path) -> None: """Test that exit_run wraps results in specified key.""" diff --git a/tests/logging/test_logging.py b/tests/logging/test_logging.py index 3be7a4c..759a4b6 100644 --- a/tests/logging/test_logging.py +++ b/tests/logging/test_logging.py @@ -7,6 +7,7 @@ import pytest +from extended_data.containers import ExtendedDict, ExtendedSet, ExtendedString from extended_data.logging import Logging from extended_data.logging.log_types import LogLevel @@ -22,6 +23,16 @@ def test_logger_initialization() -> None: assert logger.logger is not None +def test_logger_does_not_write_files_by_default(tmp_path, monkeypatch) -> None: + """Default logging should not create files in the caller's working directory.""" + monkeypatch.chdir(tmp_path) + + logger = Logging(logger_name="default_logger") + + assert logger.enable_file is False + assert not (tmp_path / "default_logger.log").exists() + + def test_basic_logging(logger: Logging) -> None: """Test basic message logging without any markers or verbosity. @@ -45,6 +56,25 @@ def test_json_logging(logger: Logging) -> None: assert "value" in result +def test_logging_redacts_sensitive_message_and_json_payloads(logger: Logging) -> None: + """Runtime log messages apply the shared Tier 1 redaction policy.""" + result = logger.logged_statement( + "Request failed with Authorization: Bearer raw_token", + json_data={"password": "hunter2", "nested": {"api_key": "key_123"}}, + labeled_json_data={"Request": {"client_secret": "secret_123"}}, + storage_marker="events", + log_level="info", + ) + + assert result is not None + stored = next(iter(logger.stored_messages["events"])) + for raw_secret in ["raw_token", "hunter2", "key_123", "secret_123"]: + assert raw_secret not in result + assert raw_secret not in stored + assert result.count("[REDACTED]") >= 4 + assert stored.count("[REDACTED]") >= 4 + + def test_storage_marker(logger: Logging) -> None: """Test storing messages under specific markers. @@ -60,8 +90,34 @@ def test_storage_marker(logger: Logging) -> None: ) assert result == msg + assert isinstance(logger.stored_messages, ExtendedDict) assert storage_marker in logger.stored_messages + assert isinstance(logger.stored_messages[storage_marker], ExtendedSet) assert msg in logger.stored_messages[storage_marker] + stored_msg = next(iter(logger.stored_messages[storage_marker])) + assert isinstance(stored_msg, ExtendedString) + + +def test_stored_message_snapshots_are_detached_extended_collections(logger: Logging) -> None: + """Stored logging data can be consumed through promoted detached snapshots.""" + logger.logged_statement("First message", storage_marker="events", log_level="info") # type: ignore[arg-type] + logger.logged_statement("Second message", storage_marker="events", log_level="info") # type: ignore[arg-type] + + messages = logger.get_stored_messages("events") + snapshot = logger.snapshot_stored_messages() + missing = logger.get_stored_messages("missing") + + messages.add("Local mutation") + snapshot["events"].add("Snapshot mutation") + + assert isinstance(messages, ExtendedSet) + assert all(isinstance(message, ExtendedString) for message in messages) + assert isinstance(snapshot, ExtendedDict) + assert isinstance(snapshot["events"], ExtendedSet) + assert missing == set() + assert "Local mutation" not in logger.stored_messages["events"] + assert "Snapshot mutation" not in logger.stored_messages["events"] + assert sorted(logger.snapshot_stored_messages().to_export_safe()["events"]) == ["First message", "Second message"] def test_context_marker(logger: Logging) -> None: @@ -174,6 +230,8 @@ def test_log_level_filtering(logger: Logging) -> None: # Allowed level should be stored logger.logged_statement(msg, log_level="info") # type: ignore[arg-type] + assert isinstance(logger.stored_messages, ExtendedDict) + assert isinstance(logger.stored_messages[storage_marker], ExtendedSet) assert msg in logger.stored_messages[storage_marker] # Denied level should not be stored @@ -220,7 +278,9 @@ def test_all_log_levels(logger: Logging, log_level: LogLevel) -> None: assert result == msg assert storage_marker in logger.stored_messages + assert isinstance(logger.stored_messages[storage_marker], ExtendedSet) stored_msg = next(iter(m for m in logger.stored_messages[storage_marker] if msg in m)) + assert isinstance(stored_msg, ExtendedString) # Check for warning prefix on appropriate levels if log_level not in ["debug", "info"]: diff --git a/tests/logging/test_properties.py b/tests/logging/test_properties.py index e226b06..1c667b8 100644 --- a/tests/logging/test_properties.py +++ b/tests/logging/test_properties.py @@ -6,6 +6,7 @@ from hypothesis import strategies as st from extended_data.logging import Logging +from extended_data.primitives import redact_sensitive_text # Strategy for valid log levels @@ -57,7 +58,7 @@ def test_basic_logging_properties(message: str, log_level: str) -> None: result = logger.logged_statement(message, log_level=log_level) # type: ignore[arg-type] assert result is not None - assert message in result + assert redact_sensitive_text(message) in result @given(message=log_messages, marker=marker_names) @@ -75,7 +76,7 @@ def test_context_marker_properties(message: str, marker: str) -> None: ) assert result is not None - assert f"[{marker}]" in result + assert redact_sensitive_text(f"[{marker}]") in result @given(message=log_messages, marker=marker_names) @@ -93,7 +94,7 @@ def test_storage_marker_properties(message: str, marker: str) -> None: ) assert marker in logger.stored_messages - assert message in next(iter(logger.stored_messages[marker])) + assert redact_sensitive_text(message) in next(iter(logger.stored_messages[marker])) @given(message=log_messages, verbosity=verbosity_levels, marker=marker_names) @@ -116,7 +117,7 @@ def test_verbosity_bypass_properties(message: str, verbosity: int, marker: str) ) assert result is not None - assert message in result + assert redact_sensitive_text(message) in result @given(message=log_messages, verbosity=verbosity_levels) @@ -149,6 +150,6 @@ def test_verbosity_control_properties(message: str, verbosity: int) -> None: if verbosity <= logger.verbosity_threshold: assert result is not None - assert message in result + assert redact_sensitive_text(message) in result else: assert result is None diff --git a/uv.lock b/uv.lock index 1d2047e..e7f536c 100644 --- a/uv.lock +++ b/uv.lock @@ -9,15 +9,6 @@ resolution-markers = [ "python_full_version < '3.11'", ] -[[package]] -name = "aiofiles" -version = "24.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/03/a88171e277e8caa88a4c77808c20ebb04ba74cc4681bf1e9416c862de237/aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c", size = 30247, upload-time = "2024-06-24T11:02:03.584Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896, upload-time = "2024-06-24T11:02:01.529Z" }, -] - [[package]] name = "aiohappyeyeballs" version = "2.6.2" @@ -177,18 +168,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] -[[package]] -name = "aiosqlite" -version = "0.21.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/13/7d/8bca2bf9a247c2c5dfeec1d7a5f40db6518f88d314b8bca9da29670d2671/aiosqlite-0.21.0.tar.gz", hash = "sha256:131bb8056daa3bc875608c631c678cda73922a2d4ba8aec373b19f18c17e7aa3", size = 13454, upload-time = "2025-02-03T07:30:16.235Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/10/6c25ed6de94c49f88a91fa5018cb4c0f3625f31d5be9f771ebe5cc7cd506/aiosqlite-0.21.0-py3-none-any.whl", hash = "sha256:2549cf4057f95f53dcba16f2b64e8e2791d7e1adedb13197dd8ed77bb226d7d0", size = 15792, upload-time = "2025-02-03T07:30:13.6Z" }, -] - [[package]] name = "annotated-doc" version = "0.0.4" @@ -240,15 +219,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] -[[package]] -name = "appdirs" -version = "1.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470, upload-time = "2020-05-11T07:59:51.037Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566, upload-time = "2020-05-11T07:59:49.499Z" }, -] - [[package]] name = "ast-serialize" version = "0.5.0" @@ -307,15 +277,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] -[[package]] -name = "backoff" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, -] - [[package]] name = "backports-asyncio-runner" version = "1.2.0" @@ -325,89 +286,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, ] -[[package]] -name = "bcrypt" -version = "5.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" }, - { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" }, - { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" }, - { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" }, - { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" }, - { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" }, - { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" }, - { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" }, - { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" }, - { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" }, - { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" }, - { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" }, - { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" }, - { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" }, - { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" }, - { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" }, - { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" }, - { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" }, - { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" }, - { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" }, - { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" }, - { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" }, - { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" }, - { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" }, - { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" }, - { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" }, - { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" }, - { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" }, - { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, - { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, - { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, - { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, - { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, - { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, - { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, - { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, - { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, - { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, - { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, - { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, - { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, - { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, - { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, - { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, - { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, - { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, - { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, - { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, - { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, - { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, - { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, - { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, - { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, - { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, - { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, - { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, - { url = "https://files.pythonhosted.org/packages/8a/75/4aa9f5a4d40d762892066ba1046000b329c7cd58e888a6db878019b282dc/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534", size = 271180, upload-time = "2025-09-25T19:50:38.575Z" }, - { url = "https://files.pythonhosted.org/packages/54/79/875f9558179573d40a9cc743038ac2bf67dfb79cecb1e8b5d70e88c94c3d/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4", size = 273791, upload-time = "2025-09-25T19:50:39.913Z" }, - { url = "https://files.pythonhosted.org/packages/bc/fe/975adb8c216174bf70fc17535f75e85ac06ed5252ea077be10d9cff5ce24/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911", size = 270746, upload-time = "2025-09-25T19:50:43.306Z" }, - { url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375, upload-time = "2025-09-25T19:50:45.43Z" }, -] - -[[package]] -name = "beautifulsoup4" -version = "4.13.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "soupsieve" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/85/2e/3e5079847e653b1f6dc647aa24549d68c6addb4c595cc0d902d1b19308ad/beautifulsoup4-4.13.5.tar.gz", hash = "sha256:5e70131382930e7c3de33450a2f54a63d5e4b19386eab43a5b34d594268f3695", size = 622954, upload-time = "2025-08-24T14:06:13.168Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/eb/f4151e0c7377a6e08a38108609ba5cede57986802757848688aeedd1b9e8/beautifulsoup4-4.13.5-py3-none-any.whl", hash = "sha256:642085eaa22233aceadff9c69651bc51e8bf3f874fb6d7104ece2beb24b47c4a", size = 105113, upload-time = "2025-08-24T14:06:14.884Z" }, -] - [[package]] name = "boto3" version = "1.43.26" @@ -436,31 +314,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/e6/5a5ec1033613e7812e5b19ec8c2a1889834fde336d8812d53019eac6e04a/botocore-1.43.26-py3-none-any.whl", hash = "sha256:eeb92265bae289555182a46341c998a656ab49c0dbdb762c65b30fe354fcc9e8", size = 15183593, upload-time = "2026-06-09T19:34:03.012Z" }, ] -[[package]] -name = "build" -version = "1.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "os_name == 'nt'" }, - { name = "importlib-metadata", marker = "python_full_version < '3.10.2'" }, - { name = "packaging" }, - { name = "pyproject-hooks" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/78/e0/df5e171f685f82f37b12e1f208064e24244911079d7b767447d1af7e0d70/build-1.5.0.tar.gz", hash = "sha256:302c22c3ba2a0fd5f3911918651341ebb3896176cbdec15bd421f80b1afc7647", size = 89796, upload-time = "2026-04-30T03:18:25.17Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl", hash = "sha256:13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f", size = 26018, upload-time = "2026-04-30T03:18:23.644Z" }, -] - -[[package]] -name = "case-insensitive-dictionary" -version = "0.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/5e/8de464e77e2bb6f0b47f2ac94f75cd46f4f14ba55529619e68ae5e81443e/case-insensitive-dictionary-0.2.1.tar.gz", hash = "sha256:7e94726f97eb2c0ceac53209971db50ffc996def663e5e5080d0a1acb4a42280", size = 5938, upload-time = "2022-10-18T10:35:30.298Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/94/2a5f43133bfd07d8c826f5fe277d54fb6c45c0c2ac5df3bc71c2ba8ff4ea/case_insensitive_dictionary-0.2.1-py3-none-any.whl", hash = "sha256:a8971780be1ba25e363db259515f0a2f003013465f82552e2f9aed08d5a9ca28", size = 6060, upload-time = "2022-10-18T10:35:31.701Z" }, -] - [[package]] name = "certifi" version = "2026.5.20" @@ -657,50 +510,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] -[[package]] -name = "chromadb" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "bcrypt" }, - { name = "build" }, - { name = "grpcio" }, - { name = "httpx" }, - { name = "importlib-resources" }, - { name = "jsonschema" }, - { name = "kubernetes" }, - { name = "mmh3" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "onnxruntime", version = "1.24.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "onnxruntime", version = "1.26.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-otlp-proto-grpc" }, - { name = "opentelemetry-sdk" }, - { name = "orjson" }, - { name = "overrides" }, - { name = "posthog" }, - { name = "pybase64" }, - { name = "pydantic" }, - { name = "pypika" }, - { name = "pyyaml" }, - { name = "rich" }, - { name = "tenacity" }, - { name = "tokenizers" }, - { name = "tqdm" }, - { name = "typer" }, - { name = "typing-extensions" }, - { name = "uvicorn", extra = ["standard"] }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7f/48/11851dddeadad6abe36ee071fedc99b5bdd2c324df3afa8cb952ae02798b/chromadb-1.1.1.tar.gz", hash = "sha256:ebfce0122753e306a76f1e291d4ddaebe5f01b5979b97ae0bc80b1d4024ff223", size = 1338109, upload-time = "2025-10-05T02:49:14.834Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/39/59/0d881a9b7eb63d8d2446cf67fcbb53fb8ae34991759d2b6024a067e90a9a/chromadb-1.1.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:27fe0e25ef0f83fb09c30355ab084fe6f246808a7ea29e8c19e85cf45785b90d", size = 19175479, upload-time = "2025-10-05T02:49:12.525Z" }, - { url = "https://files.pythonhosted.org/packages/94/4f/5a9fa317c84c98e70af48f74b00aa25589626c03a0428b4381b2095f3d73/chromadb-1.1.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:95aed58869683f12e7dcbf68b039fe5f576dbe9d1b86b8f4d014c9d077ccafd2", size = 18267188, upload-time = "2025-10-05T02:49:09.236Z" }, - { url = "https://files.pythonhosted.org/packages/45/1a/02defe2f1c8d1daedb084bbe85f5b6083510a3ba192ed57797a3649a4310/chromadb-1.1.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06776dad41389a00e7d63d936c3a15c179d502becaf99f75745ee11b062c9b6a", size = 18855754, upload-time = "2025-10-05T02:49:03.299Z" }, - { url = "https://files.pythonhosted.org/packages/5a/0d/80be82717e5dc19839af24558494811b6f2af2b261a8f21c51b872193b09/chromadb-1.1.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bba0096a7f5e975875ead23a91c0d41d977fbd3767f60d3305a011b0ace7afd3", size = 19893681, upload-time = "2025-10-05T02:49:06.481Z" }, - { url = "https://files.pythonhosted.org/packages/2d/6e/956e62975305a4e31daf6114a73b3b0683a8f36f8d70b20aabd466770edb/chromadb-1.1.1-cp39-abi3-win_amd64.whl", hash = "sha256:a77aa026a73a18181fd89bbbdb86191c9a82fd42aa0b549ff18d8cae56394c8b", size = 19844042, upload-time = "2025-10-05T02:49:16.925Z" }, -] - [[package]] name = "click" version = "8.1.8" @@ -840,121 +649,6 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] -[[package]] -name = "crewai" -version = "1.14.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiofiles" }, - { name = "aiosqlite" }, - { name = "appdirs" }, - { name = "chromadb" }, - { name = "click" }, - { name = "crewai-cli" }, - { name = "crewai-core" }, - { name = "httpx" }, - { name = "instructor" }, - { name = "json-repair" }, - { name = "json5" }, - { name = "jsonref" }, - { name = "lancedb" }, - { name = "mcp" }, - { name = "openai" }, - { name = "openpyxl" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-otlp-proto-http" }, - { name = "opentelemetry-sdk" }, - { name = "pdfplumber" }, - { name = "portalocker" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "pyjwt" }, - { name = "python-dotenv" }, - { name = "pyyaml" }, - { name = "regex" }, - { name = "tokenizers" }, - { name = "tomli" }, - { name = "tomli-w" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/12/5b/b90f9dcf649040ead9fe83f2b8c0b90c3894325079a9ea093f2f663c1b5a/crewai-1.14.6.tar.gz", hash = "sha256:e8f0cfbee70ded59ede89fce42741cb2608800132bdf9c6d8e753c90b93e246f", size = 7617349, upload-time = "2026-05-28T17:05:34.821Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/e4/5f0911d242f9a7e6863aa76d00e746ab9c574cc97292477fcd544dd5ed58/crewai-1.14.6-py3-none-any.whl", hash = "sha256:8c2fcb9b20a61d266803865510cd7f4c021a4059038cf5625a3284235b104993", size = 976412, upload-time = "2026-05-28T17:05:31.984Z" }, -] - -[package.optional-dependencies] -tools = [ - { name = "crewai-tools" }, -] - -[[package]] -name = "crewai-cli" -version = "1.14.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "appdirs" }, - { name = "certifi" }, - { name = "click" }, - { name = "crewai-core" }, - { name = "cryptography" }, - { name = "httpx" }, - { name = "packaging" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "pyjwt" }, - { name = "python-dotenv" }, - { name = "rich" }, - { name = "textual" }, - { name = "tomli" }, - { name = "tomli-w" }, - { name = "uv" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/99/c3/deff669aa492d50a3b6c8a39264260c82d06c055ffe12e065b9787244d9d/crewai_cli-1.14.6.tar.gz", hash = "sha256:f9d20bdd5aa48b41ff3a830794c3e5100b1bbf9555895b019d4b02984a986b91", size = 110300, upload-time = "2026-05-28T17:05:38.091Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/5f/2dbb2fe392356bd9ed2ec03026a8e84a486110f2cb5fd67e46bff847cbf7/crewai_cli-1.14.6-py3-none-any.whl", hash = "sha256:4d8324c86b5f2456b517b6e00bcf7c6b57d9385d2b067346a2ff5511c6a6cfc8", size = 111466, upload-time = "2026-05-28T17:05:36.659Z" }, -] - -[[package]] -name = "crewai-core" -version = "1.14.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "appdirs" }, - { name = "cryptography" }, - { name = "httpx" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-otlp-proto-http" }, - { name = "opentelemetry-sdk" }, - { name = "packaging" }, - { name = "portalocker" }, - { name = "pydantic" }, - { name = "pyjwt" }, - { name = "rich" }, - { name = "tomli" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/51/29/ebd9cdf07ec790e333a5d4b269f7f325b6cf78bd8aabec52c94cba60dfd8/crewai_core-1.14.6.tar.gz", hash = "sha256:9eee3c82d29c9e812303659200ee6af9c6f43f5ff2c3cb6d7cc3b2ba371b44e1", size = 20141, upload-time = "2026-05-28T17:05:40.421Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/db/29/fbf5bb6c916592603c179d042d5704da1cdc3445edf4fb1c0546d35ccba6/crewai_core-1.14.6-py3-none-any.whl", hash = "sha256:a02a991900e648ea49fc81cf4db2ad5d776f39dc1191457423de1f19e24eea89", size = 28305, upload-time = "2026-05-28T17:05:39.147Z" }, -] - -[[package]] -name = "crewai-tools" -version = "1.14.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "beautifulsoup4" }, - { name = "crewai" }, - { name = "pymupdf" }, - { name = "python-docx" }, - { name = "pytube" }, - { name = "requests" }, - { name = "tiktoken" }, - { name = "youtube-transcript-api" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1c/4d/d3c4d6b4ea00ff66e4bb74165cb50ce02a4ced4e4f7fb2610c6d39f441d1/crewai_tools-1.14.6.tar.gz", hash = "sha256:a8ac2afe1b648ecdd58fa68cf9c77040ae011c1a9f9a848d512ca0d6c8f97fd7", size = 895013, upload-time = "2026-05-28T17:05:46.644Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/75/da5801576a5bbb81d0b59e4acb0fe5c509dcd0535249f77fda0d4758dd54/crewai_tools-1.14.6-py3-none-any.whl", hash = "sha256:411a09eab6b8fa713a49f2820314e74f6d693a6600aef785a498e8f42ec9eb71", size = 809650, upload-time = "2026-05-28T17:05:44.721Z" }, -] - [[package]] name = "cryptography" version = "48.0.1" @@ -1015,76 +709,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/8c/ce3823c06c2804f194f9e64f0d67fa3f4094a39f2bb1a990cd03603af8fc/cryptography-48.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a", size = 3742204, upload-time = "2026-06-09T22:31:34.773Z" }, ] -[[package]] -name = "cuda-bindings" -version = "13.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cuda-pathfinder" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/21/8464d133752951c154feafb3b65c297e7d80f301183d220bec4c830f1441/cuda_bindings-13.3.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:120fcc53d57903df529c3486962c56528cba5b7d6c57c99537320ed9922c8b86", size = 6073403, upload-time = "2026-05-29T23:11:36.22Z" }, - { url = "https://files.pythonhosted.org/packages/a8/1f/5ef51f5fbaa5d4d3201bb3d7555af028ec1aa4416275ccbf73c9e34e3d2d/cuda_bindings-13.3.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9851b0caa8bfd3bc6fa054eaf57bea7c8e9c3a62db2d2621224677f49f3c53d0", size = 6675244, upload-time = "2026-05-29T23:11:38.664Z" }, - { url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" }, - { url = "https://files.pythonhosted.org/packages/95/7a/c5e3c34a409b148f5c0f5a4ea374158f95d488862c1dffedf9aa5c639df9/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04436a9364059c84b8f9636f359eccda1cf814341f5b670c71d80d2f79dbc708", size = 6674166, upload-time = "2026-05-29T23:11:45.478Z" }, - { url = "https://files.pythonhosted.org/packages/ce/67/5e7dba1ba576dd73da5dee894ca076ca5e959450dfff66d6d510a255d1f7/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7855c4868aabc0cfae28abbe83d56734bdfbd08f08fc234ac1912a12858bf49", size = 6025351, upload-time = "2026-05-29T23:11:49.685Z" }, - { url = "https://files.pythonhosted.org/packages/39/2a/6d2e9047d1fb243dbaa364b01e0297534b9ed7fd27dba1c9f361519cf69b/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e32d08f71ebcdf00f0f41eab2eb37e8da94c8ed411cc9f7f7a019ce6b34abe3a", size = 6657965, upload-time = "2026-05-29T23:11:52.227Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6e/2394f8163360f8391f8f1b7e72d300a82724edb81a7b7084c799fbd4c91f/cuda_bindings-13.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9efb21c1ee64981e184b9e0ba5eb3179e5ba3d4b51665a6cb52b8ef3d01a7cbf", size = 5920504, upload-time = "2026-05-29T23:11:56.883Z" }, - { url = "https://files.pythonhosted.org/packages/34/c2/ef9b6a63f7dc432712a462c816662e662e00d38caa9b861c8c2588195d03/cuda_bindings-13.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2732904099e0a4d4db774a5fc6d91ee95fae065b4d2ecabb4968c5fe2406c9d7", size = 6476660, upload-time = "2026-05-29T23:11:59.188Z" }, - { url = "https://files.pythonhosted.org/packages/b1/81/bff68ce829999c1e4209c761bbf903b1c06ec570416ddb25020864ad5907/cuda_bindings-13.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ab2f74ed65bfef4163ba07a8db16f1085e0729291db12a2423aff84ee8278b8", size = 6013639, upload-time = "2026-05-29T23:12:03.509Z" }, - { url = "https://files.pythonhosted.org/packages/d4/e0/c8a1f0c8f9ffdea4f5fe6dbab89b326cef4d85caf489dad39e209da89416/cuda_bindings-13.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd4c814d311ec08c981f6dded1dbe7d4b371067ee4f6c14cccec4bde9590f80", size = 6534419, upload-time = "2026-05-29T23:12:05.633Z" }, - { url = "https://files.pythonhosted.org/packages/52/b8/83b1f563925b290f2d11a01a77a84013ba56052fe3653a5bef3ccfbb43d6/cuda_bindings-13.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3c772dfff49681541d59630c90f858e173ac926b9c593a2b7123f2a1043cc76", size = 5809771, upload-time = "2026-05-29T23:12:10.422Z" }, - { url = "https://files.pythonhosted.org/packages/12/20/e79b4bfe98f075195afb6343d41c498f9dbd2d161d7021d4d28bceb83581/cuda_bindings-13.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36febb7c1079d68a981dbbd8d5a67235b399802b82075c9388624719607e52b9", size = 6358584, upload-time = "2026-05-29T23:12:12.767Z" }, -] - -[[package]] -name = "cuda-pathfinder" -version = "1.5.5" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/11/c8/26f2e4aae92f11522a96043892ba39a90eac610d5242523aa863212bc1c7/cuda_pathfinder-1.5.5-py3-none-any.whl", hash = "sha256:0228c023f95d1480f143ef5c8922d27a2ab052087a942e81dc289c9eb8f91689", size = 51671, upload-time = "2026-05-27T01:21:25.413Z" }, -] - -[[package]] -name = "cuda-toolkit" -version = "13.0.2" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/57/b2/453099f5f3b698d7d0eab38916aac44c7f76229f451709e2eb9db6615dcd/cuda_toolkit-13.0.2-py2.py3-none-any.whl", hash = "sha256:b198824cf2f54003f50d64ada3a0f184b42ca0846c1c94192fa269ecd97a66eb", size = 2364, upload-time = "2025-12-19T23:24:07.328Z" }, -] - -[package.optional-dependencies] -cudart = [ - { name = "nvidia-cuda-runtime", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, -] -cufft = [ - { name = "nvidia-cufft", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, -] -cufile = [ - { name = "nvidia-cufile", marker = "sys_platform == 'linux'" }, -] -cupti = [ - { name = "nvidia-cuda-cupti", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, -] -curand = [ - { name = "nvidia-curand", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, -] -cusolver = [ - { name = "nvidia-cusolver", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, -] -cusparse = [ - { name = "nvidia-cusparse", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, -] -nvjitlink = [ - { name = "nvidia-nvjitlink", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, -] -nvrtc = [ - { name = "nvidia-cuda-nvrtc", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, -] -nvtx = [ - { name = "nvidia-nvtx", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, -] - [[package]] name = "deepmerge" version = "2.0" @@ -1094,27 +718,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2d/82/e5d2c1c67d19841e9edc74954c827444ae826978499bde3dfc1d007c8c11/deepmerge-2.0-py3-none-any.whl", hash = "sha256:6de9ce507115cff0bed95ff0ce9ecc31088ef50cbdf09bc90a09349a318b3d00", size = 13475, upload-time = "2024-08-30T05:31:48.659Z" }, ] -[[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 = "deprecation" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5a/d3/8ae2869247df154b64c1884d7346d412fed0c49df84db635aab2d1c40e62/deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff", size = 173788, upload-time = "2020-04-20T14:23:38.738Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178, upload-time = "2020-04-20T14:23:36.581Z" }, -] - [[package]] name = "distro" version = "1.9.0" @@ -1139,24 +742,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, ] -[[package]] -name = "durationpy" -version = "0.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/a4/e44218c2b394e31a6dd0d6b095c4e1f32d0be54c2a4b250032d717647bab/durationpy-0.10.tar.gz", hash = "sha256:1fa6893409a6e739c9c72334fc65cca1f355dbdd93405d30f726deb5bde42fba", size = 3335, upload-time = "2025-05-17T13:52:37.26Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/0d/9feae160378a3553fa9a339b0e9c1a048e147a4127210e286ef18b730f03/durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286", size = 3922, upload-time = "2025-05-17T13:52:36.463Z" }, -] - -[[package]] -name = "et-xmlfile" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, -] - [[package]] name = "exceptiongroup" version = "1.3.1" @@ -1183,7 +768,6 @@ name = "extended-data" version = "7.0.0" source = { editable = "." } dependencies = [ - { name = "case-insensitive-dictionary" }, { name = "deepmerge" }, { name = "gitpython" }, { name = "httpx" }, @@ -1200,23 +784,21 @@ dependencies = [ { name = "sortedcontainers" }, { name = "tenacity" }, { name = "tomlkit" }, + { name = "typing-extensions" }, { name = "validators" }, { name = "wrapt" }, ] [package.optional-dependencies] ai = [ - { name = "crewai", extra = ["tools"] }, { name = "langchain-core" }, { name = "langsmith" }, { name = "mcp" }, { name = "strands-agents" }, - { name = "uv" }, ] all = [ { name = "anthropic" }, { name = "boto3" }, - { name = "crewai", extra = ["tools"] }, { name = "fastapi" }, { name = "filelock" }, { name = "google-api-python-client" }, @@ -1231,12 +813,13 @@ all = [ { name = "pygithub" }, { name = "pyngrok" }, { name = "python-graphql-client" }, - { name = "sentence-transformers" }, + { name = "pyyaml" }, + { name = "rich" }, { name = "slack-sdk" }, { name = "sqlite-vec" }, { name = "strands-agents" }, - { name = "uv" }, { name = "uvicorn" }, + { name = "validators" }, ] anthropic = [ { name = "anthropic" }, @@ -1244,10 +827,6 @@ anthropic = [ aws = [ { name = "boto3" }, ] -crewai = [ - { name = "crewai", extra = ["tools"] }, - { name = "uv" }, -] dev = [ { name = "coverage", extra = ["toml"] }, { name = "hypothesis" }, @@ -1315,7 +894,6 @@ vault = [ { name = "hvac" }, ] vector = [ - { name = "sentence-transformers" }, { name = "sqlite-vec" }, ] webhooks = [ @@ -1330,11 +908,7 @@ requires-dist = [ { name = "anthropic", marker = "extra == 'anthropic'", specifier = ">=0.96.0" }, { name = "boto3", marker = "extra == 'all'", specifier = ">=1.42.92" }, { name = "boto3", marker = "extra == 'aws'", specifier = ">=1.42.92" }, - { name = "case-insensitive-dictionary", specifier = ">=0.2.1" }, { name = "coverage", extras = ["toml"], marker = "extra == 'tests'", specifier = ">=7.6.0" }, - { name = "crewai", extras = ["tools"], marker = "extra == 'ai'", specifier = ">=1.14.2rc1" }, - { name = "crewai", extras = ["tools"], marker = "extra == 'all'", specifier = ">=1.14.2rc1" }, - { name = "crewai", extras = ["tools"], marker = "extra == 'crewai'", specifier = ">=1.14.2rc1" }, { name = "deepmerge", specifier = ">=2.0" }, { name = "extended-data", extras = ["tests", "typing"], marker = "extra == 'dev'" }, { name = "fastapi", marker = "extra == 'all'", specifier = ">=0.136.0" }, @@ -1383,14 +957,14 @@ requires-dist = [ { name = "python-graphql-client", marker = "extra == 'github'", specifier = ">=0.4.3" }, { name = "python-hcl2", specifier = ">=4.3.4" }, { name = "pyyaml", specifier = ">=6.0.1" }, + { name = "pyyaml", marker = "extra == 'all'", specifier = ">=6.0.3" }, { name = "pyyaml", marker = "extra == 'secrets'", specifier = ">=6.0.3" }, { name = "requests", specifier = ">=2.33.1" }, { name = "rich", specifier = ">=13.7.1" }, + { name = "rich", marker = "extra == 'all'", specifier = ">=13.7.0,<15.0.0" }, { name = "rich", marker = "extra == 'meshy'", specifier = ">=13.7.0,<15.0.0" }, { name = "ruamel-yaml", specifier = ">=0.18.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" }, - { name = "sentence-transformers", marker = "extra == 'all'", specifier = ">=5.4.1" }, - { name = "sentence-transformers", marker = "extra == 'vector'", specifier = ">=5.4.1" }, { name = "slack-sdk", marker = "extra == 'all'", specifier = ">=3.41.0" }, { name = "slack-sdk", marker = "extra == 'slack'", specifier = ">=3.41.0" }, { name = "sortedcontainers", specifier = ">=2.4.0" }, @@ -1404,16 +978,15 @@ requires-dist = [ { name = "tomlkit", specifier = ">=0.13.2" }, { name = "types-pyyaml", marker = "extra == 'typing'", specifier = ">=6.0.12.20240724" }, { name = "types-requests", marker = "extra == 'typing'", specifier = ">=2.33.0.20260408" }, - { name = "uv", marker = "extra == 'ai'", specifier = ">=0.11.7" }, - { name = "uv", marker = "extra == 'all'", specifier = ">=0.11.7" }, - { name = "uv", marker = "extra == 'crewai'", specifier = ">=0.11.7" }, + { name = "typing-extensions", specifier = ">=4.12.2" }, { name = "uvicorn", marker = "extra == 'all'", specifier = ">=0.45.0" }, { name = "uvicorn", marker = "extra == 'webhooks'", specifier = ">=0.45.0" }, { name = "validators", specifier = ">=0.22.0" }, + { name = "validators", marker = "extra == 'all'", specifier = ">=0.35.0" }, { name = "validators", marker = "extra == 'meshy'", specifier = ">=0.35.0" }, { name = "wrapt", specifier = ">=1.16.0" }, ] -provides-extras = ["aws", "google", "github", "slack", "vault", "zoom", "anthropic", "cursor", "meshy", "secrets", "langchain", "crewai", "strands", "mcp", "ai", "webhooks", "vector", "tests", "typing", "dev", "all"] +provides-extras = ["aws", "google", "github", "slack", "vault", "zoom", "anthropic", "cursor", "meshy", "secrets", "langchain", "strands", "mcp", "ai", "webhooks", "vector", "tests", "typing", "dev", "all"] [[package]] name = "fastapi" @@ -1440,14 +1013,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4c/a0/614c5fe402fd88951df45f4dda2fa3b4e17a99ecd92340771929169b3b95/filelock-3.29.1-py3-none-any.whl", hash = "sha256:85199dfd706869641b72b2e8955d5416a4b2b7dc4b0e8e6d97b4cc1299a6983b", size = 40750, upload-time = "2026-06-03T15:19:02.959Z" }, ] -[[package]] -name = "flatbuffers" -version = "25.12.19" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, -] - [[package]] name = "frozenlist" version = "1.8.0" @@ -1569,15 +1134,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] -[[package]] -name = "fsspec" -version = "2026.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d5/8d/1c51c094345df128ca4a990d633fe1a0ff28726c9e6b3c41ba65087bba1d/fsspec-2026.4.0.tar.gz", hash = "sha256:301d8ac70ae90ef3ad05dcf94d6c3754a097f9b5fe4667d2787aa359ec7df7e4", size = 312760, upload-time = "2026-04-29T20:42:38.635Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-py3-none-any.whl", hash = "sha256:11ef7bb35dab8a394fde6e608221d5cf3e8499401c249bebaeaad760a1a8dec2", size = 203402, upload-time = "2026-04-29T20:42:36.842Z" }, -] - [[package]] name = "gitdb" version = "4.0.12" @@ -1815,38 +1371,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] -[[package]] -name = "hf-xet" -version = "1.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4b/2d/57fd21d84d93efb4bd0b962383790e19dd1bc053501b4264c97903b4e83e/hf_xet-1.5.1.tar.gz", hash = "sha256:51ef4500dab3764b41135ee1381a4b62ce56fc54d4c92b719b59e597d6df5bf6", size = 876636, upload-time = "2026-06-08T23:02:53.897Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/ee/dd9ba7beae1005e54131b7d45263cc74c8a066d47d354e6d58ae9445a388/hf_xet-1.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:dbf48c0d02cf0b2e568944330c60d9120c272dabe013bd892d48e25bc6797577", size = 4069485, upload-time = "2026-06-08T23:02:13.193Z" }, - { url = "https://files.pythonhosted.org/packages/b6/bc/9cae6cfeb4e03070874e73e5c97c66eb90369d3206b6a2b1ef5f96520888/hf_xet-1.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78e4e5192ad2b674c2e1160b651cb9134db974f8ae1835bdfbfb0166b894a43", size = 3838493, upload-time = "2026-06-08T23:02:15.282Z" }, - { url = "https://files.pythonhosted.org/packages/ba/b4/d5c01e0eb6d9f2ca2dacd84d0d1b71e6cfbb2ef3208c968528e010e9b3d7/hf_xet-1.5.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6f7a04a8ad962422e225bc49fbbac99dc1806764b1f3e54dbd154bffa7593947", size = 4505658, upload-time = "2026-06-08T23:02:17.196Z" }, - { url = "https://files.pythonhosted.org/packages/76/c5/29a7598c0c6383c523dc22186d577f4e04267a626cd95ae60f67c00bfe66/hf_xet-1.5.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d48199c2bf4f8df0adc55d31d1368b6ec0e4d4f45bc86b08038089c23db0bed8", size = 4292822, upload-time = "2026-06-08T23:02:18.608Z" }, - { url = "https://files.pythonhosted.org/packages/04/9a/dceaf6ca69390126b86ea825fb354b93d01163199070b7bd849225de9468/hf_xet-1.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:97f212a88d14bbf573619a74b7fecb238de77d08fc702e54dec6f78276ca3283", size = 4491255, upload-time = "2026-06-08T23:02:20.124Z" }, - { url = "https://files.pythonhosted.org/packages/48/a7/e5a7afaacf6c1791fdbeeac42951fb81c3d2bc482992b115dedcc86d963e/hf_xet-1.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f61e3665892a6c8c5e765395838b8ddf36185da835253d4bc4509a81e49fb342", size = 4711062, upload-time = "2026-06-08T23:02:21.863Z" }, - { url = "https://files.pythonhosted.org/packages/53/49/2802f8433c9742ce281bddc1e65c02c32268ca3098d66828b05e12e45ee2/hf_xet-1.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f4ad3ebd4c32dd2b27099d69dc7b2df821e30767e46fb6ee6a0713778243b8ff", size = 4017205, upload-time = "2026-06-08T23:02:23.495Z" }, - { url = "https://files.pythonhosted.org/packages/9e/5a/50c71195b9fb883659f596e7252faf4c18c58e753a9013bdbf9bac5d2250/hf_xet-1.5.1-cp313-cp313t-win_arm64.whl", hash = "sha256:8298485c1e36e7e67cbd01eeb1376619b7af43d4f1ec245caae306f890a8a32d", size = 3845426, upload-time = "2026-06-08T23:02:25.124Z" }, - { url = "https://files.pythonhosted.org/packages/05/24/5e0c28f80371c17d49fed004597d9d132cb75c1f6f53db2cb95f459d2312/hf_xet-1.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3474760d10e3bb6f92ff3f024fcb00c0b3e4001e9b035c7483e49a5dd17aa70f", size = 4069676, upload-time = "2026-06-08T23:02:26.759Z" }, - { url = "https://files.pythonhosted.org/packages/d2/17/261ba565b6a4d960fb478f61fdf919c0be5824645aaf1c319eca660c1611/hf_xet-1.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6762d89b9e3267dfd502b29b2a327b4525f33b17e7b509a78d94e2151a30ce30", size = 3838509, upload-time = "2026-06-08T23:02:28.573Z" }, - { url = "https://files.pythonhosted.org/packages/4e/44/7ffdc2e184b0d41fc0f683ba3936ef669ab63cf242cf36ef50e57d683668/hf_xet-1.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf67e6ed10260cef62e852789dc91ebb03f382d5bdc4b1dbeb64763ea275e7d6", size = 4505881, upload-time = "2026-06-08T23:02:30.257Z" }, - { url = "https://files.pythonhosted.org/packages/63/b6/788060d5aa4d5e671f1a31bf69624c314eb2d8babab3aa562f9e5d53444e/hf_xet-1.5.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c6b6cd08ca095058780b50b8ce4d6cbf6787bcf27841705d58a9d32246e3e47a", size = 4292995, upload-time = "2026-06-08T23:02:31.993Z" }, - { url = "https://files.pythonhosted.org/packages/22/93/c5540cbd6b55529b7dc42f6734e88cebee21aefbea34128b66229df56c57/hf_xet-1.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e1af0de8ca6f190d4294a28b88023db64a1e2d1d719cab044baf75bec569e7a9", size = 4491570, upload-time = "2026-06-08T23:02:33.86Z" }, - { url = "https://files.pythonhosted.org/packages/03/f3/9d8ceab30f44f36c1679b1b8683054c71a0dadc787dbf07421891742d3ca/hf_xet-1.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4f561cbbb92f80960772059864b7fb07eae879adde1b2e781ec6f86f6ac26c59", size = 4711565, upload-time = "2026-06-08T23:02:35.454Z" }, - { url = "https://files.pythonhosted.org/packages/cd/54/27ed9a5e2cc583b4df82f75a03a4df8dbf55f5a9fa1f47f1fadfb20dbeac/hf_xet-1.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:e7dbb40617410f432182d918e37c12303fe6700fd6aa6c5964e30a535a4461d6", size = 4017343, upload-time = "2026-06-08T23:02:37.14Z" }, - { url = "https://files.pythonhosted.org/packages/ae/12/ecb2fc8d45e767580e3a37faa97cb895608b614965567efb4f18cff67e27/hf_xet-1.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6071d5ccb4d8d2cbd5fea5cc798da4f0ba3f44e25369591c4e89a4987050e61d", size = 3845716, upload-time = "2026-06-08T23:02:39.073Z" }, - { url = "https://files.pythonhosted.org/packages/7a/d8/5e54cf37434759d1f4f2ba9b66077ff9d4c4e1f37b6bd7975da5c40d94ab/hf_xet-1.5.1-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:6abd35c3221eff63836618ddfb954dcf84798603f71d8e33e3ed7b04acfdbe6e", size = 4077794, upload-time = "2026-06-08T23:02:40.656Z" }, - { url = "https://files.pythonhosted.org/packages/35/94/4b2ecfbad8f8b04701a23aefb62f540b9137d058b7e1dbef16a32676f0e9/hf_xet-1.5.1-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:94e761bbd266bf4c03cee73753916062665ce8365aa40ed321f45afcb934b41e", size = 3845354, upload-time = "2026-06-08T23:02:42.702Z" }, - { url = "https://files.pythonhosted.org/packages/de/cc/f99f4bc7295023d7bd9ebbfd51f75cc530ca262c1227666268b8208f4b77/hf_xet-1.5.1-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:892e3a3a3aecc12aded8b93cf4f9cd059282c7de0732f7d55026f3abdf474350", size = 4514864, upload-time = "2026-06-08T23:02:44.497Z" }, - { url = "https://files.pythonhosted.org/packages/cd/6e/21f7e5a2381278bd3b7b7a5a4d90038518bb6308a0c1daf5d9f8268bb178/hf_xet-1.5.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a93df2039190502835b1db8cd7e178b0b7b889fe9ab51299d5ced26e0dd879a4", size = 4303784, upload-time = "2026-06-08T23:02:46.203Z" }, - { url = "https://files.pythonhosted.org/packages/35/0e/f992bb6927ac1cb30ef74e62268f551f338bc32b2191f7c96a44c6f7283e/hf_xet-1.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0c97106032ef70467b4f6bc2d0ccc266d7613ee076afc56516c502f87ce1c4a6", size = 4500703, upload-time = "2026-06-08T23:02:47.628Z" }, - { url = "https://files.pythonhosted.org/packages/fb/d1/90a498d05447980b977b1669246eeeeae4cfb0ea3e7a286eaba627f91bf9/hf_xet-1.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6208adb15d192b90e4c2ad2a27ed864359b2cb0f2494eb6d7c7f3699ac02e2bf", size = 4719498, upload-time = "2026-06-08T23:02:49.268Z" }, - { url = "https://files.pythonhosted.org/packages/6d/b6/20f99cfe97cc663a711f7b33cc21d4793e51968e9a26125b4afcd77315ba/hf_xet-1.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:f7b3002f95d1c13e24bcb4537baa8f0eb3838957067c91bb4959bc004a6435f5", size = 4026419, upload-time = "2026-06-08T23:02:50.829Z" }, - { url = "https://files.pythonhosted.org/packages/f9/fa/77453694888f03e5a8c8852d1514a0894d8e81c622d39edbaf308ea0dcf4/hf_xet-1.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:93d090b57b211133f6c0dab0205ef5cb6d89162979ba75a74845045cc3063b8e", size = 3855178, upload-time = "2026-06-08T23:02:52.452Z" }, -] - [[package]] name = "httpcore" version = "1.0.9" @@ -1872,56 +1396,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2f/90/fd509079dfcab01102c0fdd87f3a9506894bc70afcf9e9785ef6b2b3aff6/httplib2-0.31.2-py3-none-any.whl", hash = "sha256:dbf0c2fa3862acf3c55c078ea9c0bc4481d7dc5117cae71be9514912cf9f8349", size = 91099, upload-time = "2026-01-23T11:04:42.78Z" }, ] -[[package]] -name = "httptools" -version = "0.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/40/b9/be66eb0decd730d89b9c94f930e4b8d87787b05724bb84af98bfd825f72c/httptools-0.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bf3b6f807c8541503cecfbb8a8dffb385640d0d96102f3d112aa8740f9b7c826", size = 208805, upload-time = "2026-05-25T22:16:50.434Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f7/b4d41eaae2869d31356bc4bbf546f44fae83ff298af0a043ca0625b06773/httptools-0.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da684f2e1aa2ee9bdcb083f3f3a68c5956750b375bc5df864d3a5f0c42a40b77", size = 113527, upload-time = "2026-05-25T22:16:51.672Z" }, - { url = "https://files.pythonhosted.org/packages/e6/e4/77487e14fc7be47180fd0eb4267c7486d0cc59b74031839a3daf8650136b/httptools-0.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6f21e2a3b0067bbe7f67e34cfd16276af556e5e52f4c7503be0cb5f90e905e4", size = 450035, upload-time = "2026-05-25T22:16:53.313Z" }, - { url = "https://files.pythonhosted.org/packages/da/72/5a8f787e323f56fbd86c32a4be92a86776e4cfe8b4317db999f452028362/httptools-0.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea897f0c729581ebf72131a438a7932d9b14efef72d75ada966700cac3caaeb", size = 451101, upload-time = "2026-05-25T22:16:54.696Z" }, - { url = "https://files.pythonhosted.org/packages/ed/41/b44a25560955197674b6744cb903664300e239235a5eaa69df0890d87054/httptools-0.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c0d726cc107fceb7d45f978483b4b70dd8caa836f5914d3434bb18628eb73813", size = 436140, upload-time = "2026-05-25T22:16:56.239Z" }, - { url = "https://files.pythonhosted.org/packages/74/b0/054aac84c03d7e097bf4c605fb7e74eec3d65c0276adf64ee97f3a103ff5/httptools-0.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9878eb2785ba5eb70631ad269b37976f73d647955e26c91d490eb8a4edfda4ba", size = 437041, upload-time = "2026-05-25T22:16:57.716Z" }, - { url = "https://files.pythonhosted.org/packages/bb/e8/86b85bbc0ac7892232f1a99ab96a9aa71936984fa06adfc0afc83ca7789e/httptools-0.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:b205e5f5523fa039679da0dfe5a10132b2a4abeae6a86fdd1ddc035f7f836557", size = 90454, upload-time = "2026-05-25T22:16:58.871Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload-time = "2026-05-25T22:16:59.717Z" }, - { url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload-time = "2026-05-25T22:17:00.795Z" }, - { url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload-time = "2026-05-25T22:17:02.014Z" }, - { url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload-time = "2026-05-25T22:17:03.109Z" }, - { url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload-time = "2026-05-25T22:17:04.443Z" }, - { url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload-time = "2026-05-25T22:17:05.476Z" }, - { url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload-time = "2026-05-25T22:17:06.75Z" }, - { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, - { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, - { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, - { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, - { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, - { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, - { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, - { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, - { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, - { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, - { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, - { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, - { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, - { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, - { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, - { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, - { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, - { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, - { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, - { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, - { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, - { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, - { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, - { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, - { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, -] - [[package]] name = "httpx" version = "0.28.1" @@ -1946,26 +1420,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, ] -[[package]] -name = "huggingface-hub" -version = "1.16.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "filelock" }, - { name = "fsspec" }, - { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, - { name = "httpx" }, - { name = "packaging" }, - { name = "pyyaml" }, - { name = "tqdm" }, - { name = "typer" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/48/0f/ed994dbade67a54407c28cab96ef845e0e6d25500be56aca6394f8bfc9dd/huggingface_hub-1.16.1.tar.gz", hash = "sha256:7f1dc4c5ec21aed69be630ad0c3378616be16f3de1a47b141c0e812965d9c832", size = 792534, upload-time = "2026-05-21T18:40:00.908Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/79/621a7dbb80c70974f73a597275351ebe03ce5bc65cb5f8f4acb5859252bc/huggingface_hub-1.16.1-py3-none-any.whl", hash = "sha256:64340de934b9ce37857ef85a82de72f5629e8a270f9119eabb12bf495eb53c22", size = 668176, upload-time = "2026-05-21T18:39:58.596Z" }, -] - [[package]] name = "hvac" version = "2.4.0" @@ -2012,15 +1466,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, ] -[[package]] -name = "importlib-resources" -version = "7.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/06/b56dfa750b44e86157093bc8fca0ab81dccbf5260510de4eaf1cb69b5b99/importlib_resources-7.1.0.tar.gz", hash = "sha256:0722d4c6212489c530f2a145a34c0a7a3b4721bc96a15fada5930e2a0b760708", size = 44985, upload-time = "2026-04-12T16:36:09.232Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/db/55a262f3606bebcae07cc14095338471ad7c0bbcaa37707e6f0ee49725b7/importlib_resources-7.1.0-py3-none-any.whl", hash = "sha256:1bd7b48b4088eddb2cd16382150bb515af0bd2c70128194392725f82ad2c96a1", size = 37232, upload-time = "2026-04-12T16:36:08.219Z" }, -] - [[package]] name = "inflection" version = "0.5.1" @@ -2039,40 +1484,6 @@ 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 = "instructor" -version = "1.15.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohttp" }, - { name = "docstring-parser" }, - { name = "jinja2" }, - { name = "jiter" }, - { name = "openai" }, - { name = "pydantic" }, - { name = "pydantic-core" }, - { name = "requests" }, - { name = "rich" }, - { name = "tenacity" }, - { name = "typer" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/dc/a4/832cfb15420360e26d2d85bd9d5fe1e4b839d52587574d389bc31284bf6f/instructor-1.15.1.tar.gz", hash = "sha256:c72406469d9025b742e83cf0c13e914b317db2089d08d889944e74fcd659ef94", size = 69948370, upload-time = "2026-04-03T01:51:30.107Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/c8/36c5d9b80aaf40ba9a7084a8fc18c967db6bf248a4cc8d0f0816b14284be/instructor-1.15.1-py3-none-any.whl", hash = "sha256:be81d17ba2b154a04ab4720808f24f9d6b598f80992f82eaf9cc79006099cf6c", size = 178156, upload-time = "2026-04-03T01:51:23.098Z" }, -] - -[[package]] -name = "jinja2" -version = "3.1.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, -] - [[package]] name = "jiter" version = "0.13.0" @@ -2179,33 +1590,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, ] -[[package]] -name = "joblib" -version = "1.5.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, -] - -[[package]] -name = "json-repair" -version = "0.25.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7c/60/484ee009c1867ddc5ffe0ff2131b82e80bbf13fdb59f3d93834f98e56a9f/json_repair-0.25.3.tar.gz", hash = "sha256:4ee970581a05b0b258b749eb8bcac21de380edda97c3717a4edfafc519ec21a4", size = 20619, upload-time = "2024-07-10T13:42:18.977Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/9e/2ab68cc0ff030e1ef78329d7b933473d3ad2c7d0e66aede6a7c87f74753c/json_repair-0.25.3-py3-none-any.whl", hash = "sha256:f00b510dd21b31ebe72581bdb07e66381df2883d6f640c89605e482882c12b17", size = 12812, upload-time = "2024-07-10T13:42:16.918Z" }, -] - -[[package]] -name = "json5" -version = "0.10.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/85/3d/bbe62f3d0c05a689c711cff57b2e3ac3d3e526380adb7c781989f075115c/json5-0.10.0.tar.gz", hash = "sha256:e66941c8f0a02026943c52c2eb34ebeb2a6f819a0be05920a6f5243cd30fd559", size = 48202, upload-time = "2024-11-26T19:56:37.823Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/42/797895b952b682c3dafe23b1834507ee7f02f4d6299b65aaa61425763278/json5-0.10.0-py3-none-any.whl", hash = "sha256:19b23410220a7271e8377f81ba8aacba2fdd56947fbb137ee5977cbe1f5e8dfa", size = 34049, upload-time = "2024-11-26T19:56:36.649Z" }, -] - [[package]] name = "jsonpatch" version = "1.33" @@ -2227,15 +1611,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, ] -[[package]] -name = "jsonref" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814, upload-time = "2023-01-16T16:10:04.455Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425, upload-time = "2023-01-16T16:10:02.255Z" }, -] - [[package]] name = "jsonschema" version = "4.26.0" @@ -2264,78 +1639,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] -[[package]] -name = "kubernetes" -version = "36.0.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohttp" }, - { name = "certifi" }, - { name = "durationpy" }, - { name = "python-dateutil" }, - { name = "pyyaml" }, - { name = "requests" }, - { name = "requests-oauthlib" }, - { name = "six" }, - { name = "urllib3" }, - { name = "websocket-client" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2f/57/8b538af5076bc3372949d76f70ba3449bdfe52f9e6488170fa5d4f7cbe70/kubernetes-36.0.2.tar.gz", hash = "sha256:03551fcb49cae1f708f63624041e37403545b7aaed10cbf54e2b01a37a5438e3", size = 2336738, upload-time = "2026-06-01T18:20:30.785Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/46/2c/5c160dbdef7123f8cc97fd8ece7e0198627a426a2a49614845e9086feb8d/kubernetes-36.0.2-py2.py3-none-any.whl", hash = "sha256:faf9b5241b58de0c4a5069f2a0ffc8ac06fece7215156cd3d3ba081a78a858b6", size = 4617568, upload-time = "2026-06-01T18:20:28.737Z" }, -] - -[[package]] -name = "lance-namespace" -version = "0.8.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "lance-namespace-urllib3-client" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/33/fd/3a8731b2ed83ba198b15b5963c6df4836736057f23206107b0ab4a5f57fd/lance_namespace-0.8.2.tar.gz", hash = "sha256:78cd6ad2f2764bccded1d8b64474419cc5571956b68a23ad2770977ddaeb03a1", size = 11281, upload-time = "2026-06-05T04:46:23.696Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/cb/7f3cc83b8b35a27a27539c3086562d11010f10ca113808ce1078308ca5c0/lance_namespace-0.8.2-py3-none-any.whl", hash = "sha256:6531a4d8b95f201835b954a949f890d03cbc3124aca5f1dd21d999157a08935f", size = 13113, upload-time = "2026-06-05T04:46:27.781Z" }, -] - -[[package]] -name = "lance-namespace-urllib3-client" -version = "0.8.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dateutil" }, - { name = "typing-extensions" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5d/98/a0bb656a4f2d5989e1267a62acbb5a9ed8eb15ac45fbfe380b5a59dba642/lance_namespace_urllib3_client-0.8.2.tar.gz", hash = "sha256:82f0a5c9b6b7fde67326d6038b89ed807e8d14692e461246f1a7df5c36b804d6", size = 222291, upload-time = "2026-06-05T04:46:24.958Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/58/6a993bf50375170547d0e0bfe9189cc9b378b89482dc2c7bb75ef170a49a/lance_namespace_urllib3_client-0.8.2-py3-none-any.whl", hash = "sha256:cb8dc098fcd42f848eb5206fb49ebc3b5f162ee32b5c4155a5048ffd30a7cd37", size = 364909, upload-time = "2026-06-05T04:46:26.504Z" }, -] - -[[package]] -name = "lancedb" -version = "0.30.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "deprecation" }, - { name = "lance-namespace" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "overrides", marker = "python_full_version < '3.12'" }, - { name = "packaging" }, - { name = "pyarrow" }, - { name = "pydantic" }, - { name = "tqdm" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/13/2f/1577778ad57dba0c55dc13d87230583e14541c82562483ecf8bb2f8e8a00/lancedb-0.30.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:be2a9a43a65c330ccfd08115afb26106cd8d16788522fe7693d3a1f4e01ad321", size = 41959907, upload-time = "2026-03-16T23:03:04.551Z" }, - { url = "https://files.pythonhosted.org/packages/f1/ca/8c2a04ce499a2a97d1a0de2b7e84fa8166f988a9a495e1ada860110489c2/lancedb-0.30.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be6a4ba2a1799a426cbf2ba5ea2559a7389a569e9a31f2409d531ceb59d42f35", size = 43873070, upload-time = "2026-03-16T23:11:01.352Z" }, - { url = "https://files.pythonhosted.org/packages/16/68/e01bf7837454a5ce9e2f6773905e07b09a949bc88136c0773c8166ed7729/lancedb-0.30.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a967ec05f9930770aeb077bc5579769b1bedf559fcd03a592d9644084625918", size = 46891197, upload-time = "2026-03-16T23:14:39.18Z" }, - { url = "https://files.pythonhosted.org/packages/43/d1/9085ad17abd98f3a180d7860df3190b2d76f99f533c76d7c7494cec4139d/lancedb-0.30.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:05c66f40f7d4f6f24208e786c40f84b87b1b8e55505305849dd3fed3b78431a3", size = 43877660, upload-time = "2026-03-16T23:11:00.837Z" }, - { url = "https://files.pythonhosted.org/packages/ea/69/504ee25c57c3f23c80276b5b7b5e4c0f98a5197a7e9e51d3c50500d2b53a/lancedb-0.30.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:bdcd27d98554ed11b6f345b14d1307b0e2332d5654767e9ee2e23d9b2d6513d1", size = 46932144, upload-time = "2026-03-16T23:15:00.474Z" }, - { url = "https://files.pythonhosted.org/packages/2c/85/d5550f22023e672af1945394f7a06a578fcab2980ecc6666acef3428a771/lancedb-0.30.0-cp39-abi3-win_amd64.whl", hash = "sha256:4751ff0446b90be4d4dccfe05f6c105f403a05f3b8531ab99eedc1c656aca950", size = 51121310, upload-time = "2026-03-16T23:43:23.89Z" }, -] - [[package]] name = "langchain-core" version = "1.4.3" @@ -2483,136 +1786,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, ] -[[package]] -name = "linkify-it-py" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "uc-micro-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, -] - -[[package]] -name = "lxml" -version = "6.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/da/dbe4dfc01ac226fb0504fad035f4d69f3202f3502e20e68537631daddd96/lxml-6.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:09dd5b7075dc2f7709654a46543ba1ea3c2e217b2ed8fbd413a8a945a0f40f60", size = 8541124, upload-time = "2026-05-18T19:17:11.589Z" }, - { url = "https://files.pythonhosted.org/packages/78/20/f7095ed9fc2c025f9cfe71cc6ec9f1feb05624edc1812423b5f1aecf3d4b/lxml-6.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f6ac4ef4d82dff54670227a69c67782ae0b811b5cf6b17954f1e8f7502fc0d1d", size = 4602783, upload-time = "2026-05-18T19:17:20.888Z" }, - { url = "https://files.pythonhosted.org/packages/4a/a4/65c63ca98bd129f6cff7b8c2fa48953ab058cc6005b541354e7dd54d8000/lxml-6.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:556e94a63c9b04716f8e4de2abb65775061f846e89331b6c5be79183a24f98ea", size = 5002687, upload-time = "2026-05-18T19:17:01.738Z" }, - { url = "https://files.pythonhosted.org/packages/96/1d/ab7a5c4b5a394d98a94e2d0fc67bab8297597426770dd4978370fbdaf531/lxml-6.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6bf403fbb3b3e348a561a5f4f0b9961835657981c802a1df03653eef8a9074", size = 5155099, upload-time = "2026-05-18T19:17:05.159Z" }, - { url = "https://files.pythonhosted.org/packages/d0/b1/07603bfeeb891a2596d5c2a68f7d2f70f7d11c841ebe391412c69c2857b0/lxml-6.1.1-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dde6131244bba38a17c745836ba190bc753fd73c9291666287fd0a3fa3dcf30", size = 5057225, upload-time = "2026-05-18T19:17:08.117Z" }, - { url = "https://files.pythonhosted.org/packages/7a/16/cb391ee4b90186fa16d9ebcbe3ea96c71b8da3b0686386c8dcbcc3c67d44/lxml-6.1.1-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98fc784c2c1440667aeedf8465bdfe10208acf0ead656a2c68627299f546b315", size = 5287643, upload-time = "2026-05-18T19:17:11.507Z" }, - { url = "https://files.pythonhosted.org/packages/eb/d6/b619717f918fd76747448fdbaee0e769edbc70e659b5b5d0112b7020b7a3/lxml-6.1.1-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:add8cf6ddf9a65116119a28ece0f7886e30af27ba724a7594305f1d1b58a92a1", size = 5412445, upload-time = "2026-05-18T19:17:22.182Z" }, - { url = "https://files.pythonhosted.org/packages/c6/80/12bc5390ac0a3edeb579d9535e5049a5dda663438728e179d52fb319c33a/lxml-6.1.1-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cf9d57306d848218f3601fee7601fab1a327c942d56e2e97610583cb4dd74206", size = 4770864, upload-time = "2026-05-18T19:17:26.851Z" }, - { url = "https://files.pythonhosted.org/packages/0b/59/6500c09da3137f54f020e908d81cfc5ee3e8888e908fd380207afad7c2e6/lxml-6.1.1-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88136950da4d13c318bde414ce10219931937851327f44328f2df4d2c4614067", size = 5359594, upload-time = "2026-05-18T19:17:32.527Z" }, - { url = "https://files.pythonhosted.org/packages/f2/9b/f64b4cc6b7ebcf75d95af3cde934d254b5f2f10d4163928d838d86b6eb48/lxml-6.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cecdd5dfdc87b1fd87dbf81d4b037a544f47f4c744200a67013771682d67686a", size = 5107713, upload-time = "2026-05-18T19:17:04.402Z" }, - { url = "https://files.pythonhosted.org/packages/16/19/c7388ad5d3a72315d2832dc1458cbf4f2af7f2b990b606ff4876efd04511/lxml-6.1.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cd312b9692e831d2ffcad61eab31d91d4b4655a962e61de8fb410472cbcd37aa", size = 4803973, upload-time = "2026-05-18T19:17:06.545Z" }, - { url = "https://files.pythonhosted.org/packages/3f/22/76197f0bbf165f0b9e75be59be4997e5259cde973f12f098c1b54c7f5d60/lxml-6.1.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5b7328b46d49fc9477d91ae8f6d55340347d827b7734ba3ea33faae0efef1383", size = 5349925, upload-time = "2026-05-18T19:17:09.743Z" }, - { url = "https://files.pythonhosted.org/packages/24/52/d2a0cfeccb9bcdc47c7ee05cdae5d69b48c9acf20997790a6338bb0d0b3b/lxml-6.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37a58976370f36d9329d118ad0b953c5aeb9119ac9c6a4e258942a225d0573a1", size = 5309825, upload-time = "2026-05-18T19:17:13.831Z" }, - { url = "https://files.pythonhosted.org/packages/19/4a/b30944266776c2f49749ef2445aa7e78898194134b80ad776386f61b56ae/lxml-6.1.1-cp310-cp310-win32.whl", hash = "sha256:cea3f4c1af79af13cdb2da0c028111d8f8522d4f22a000c82385535f24e5cf3a", size = 3598402, upload-time = "2026-05-18T19:17:08.21Z" }, - { url = "https://files.pythonhosted.org/packages/9e/97/33691c66a4d7ec1a5a98e7c909a5b83ee45c7f7ba4cf92b1c4cf26e98079/lxml-6.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:3abf332af33a74288675d936fe861fd4344da0dd6622193fbc4f2bfbb35536b5", size = 4021295, upload-time = "2026-05-18T19:17:28.638Z" }, - { url = "https://files.pythonhosted.org/packages/d0/5f/26a4dd0e12b9456ff7b12a21af5b491eb6629680d1edd73f4140fd386bcf/lxml-6.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:8dadbe5b217ff35b6a8d16610dd710219b59b76d13f0e3f0d9f36786206e4485", size = 3667717, upload-time = "2026-05-19T19:22:44.474Z" }, - { url = "https://files.pythonhosted.org/packages/62/b0/83f481780d1548750b8ce2ec824073deef2f452d9cd1a6faff8507e3d16d/lxml-6.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:53b7d2b7a10b1c35c0a5e21e9224accf60c1bbfba523990732e521b2b73adef2", size = 8526461, upload-time = "2026-05-18T19:17:25.862Z" }, - { url = "https://files.pythonhosted.org/packages/b9/d5/30fa0f808002c7329397bfbb24e306789c0b29f04aa5842c07b174b4216f/lxml-6.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3f333630ab480244a1bff72043e511a91eb22e7595dead8653ee5612dd8f3d", size = 4595375, upload-time = "2026-05-18T19:17:34.555Z" }, - { url = "https://files.pythonhosted.org/packages/4f/d2/edb71cf0e561581a7c5eb2626244320eb04e9f8ce6d563184fd668b45073/lxml-6.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a4bbea04c97f6d78a48e3fbc1cb9116d2780b1b39e03a23f6eb9b603fd61f510", size = 4923654, upload-time = "2026-05-18T19:17:42.917Z" }, - { url = "https://files.pythonhosted.org/packages/4c/77/1bc7eeb0de4577d783fb625aa092cc9357883bba35845a3666bf1259f3dc/lxml-6.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db1d75f6617a49c1c01bc7023713e0ff59ab32c9579ae62a7674c0e34f3b0b0a", size = 5067921, upload-time = "2026-05-18T19:17:49.175Z" }, - { url = "https://files.pythonhosted.org/packages/1b/3c/c0690d74bd2bc17bc03b5b0d093569ead597dd0bfa088bf99eef8c24e19c/lxml-6.1.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a12689be69a28ddaa0ab99a5a1137da2afd5f8f16df7b5680b66f616d3eda1d", size = 5002456, upload-time = "2026-05-18T19:17:59.715Z" }, - { url = "https://files.pythonhosted.org/packages/66/8d/d1b3271af0c0f1e27e8472a849e4d2c65bc7766884b9ad2da9e76e145c88/lxml-6.1.1-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b73c339ae29b90fd2d06e58ebd555a751bde9cd6bbd36cc0281b9a2c94e9d8", size = 5202776, upload-time = "2026-05-18T19:18:08.924Z" }, - { url = "https://files.pythonhosted.org/packages/7a/45/689824ffb237fd10125ad273f32b28ff04dc6203c2822c85ff65a93df65e/lxml-6.1.1-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:752d3bbfe874715ccd0aec7f88d7fc623c0f1fd7aa7b3238a084e017bad2a009", size = 5329945, upload-time = "2026-05-18T19:18:13.673Z" }, - { url = "https://files.pythonhosted.org/packages/5d/c0/ef73af53767e958fd87d437c170f272e2f6e6c0f854939f133a895f1e711/lxml-6.1.1-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:6b1761fbf9ec984e2e9d9c589ef5f5fd684b7c19f92aadd567a26c5224958db6", size = 4659237, upload-time = "2026-05-18T19:18:18.657Z" }, - { url = "https://files.pythonhosted.org/packages/a0/5e/e1158e40397585e91cb0472374a1f63d0926a1ddeaa92f13d1a1ffe306d5/lxml-6.1.1-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d680fbcb768404c601ecb43519ecd8461f6954cb11c06a78962f666832ccfca8", size = 5265904, upload-time = "2026-05-18T19:18:24.883Z" }, - { url = "https://files.pythonhosted.org/packages/a0/16/8687e5d1400ed1c0bc41dace232ebb7553952b618ea1f2e5fb6e2cfbbe23/lxml-6.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:162af1091cd785f2f27e62d3547ae9bc58ec5c86dd314d67021fd02463708d83", size = 5045225, upload-time = "2026-05-18T19:17:20.073Z" }, - { url = "https://files.pythonhosted.org/packages/ca/18/d877bd1ae2e5ffdfd4836565aba350db31feb2f2656d6ce70316ed66a05e/lxml-6.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e9308ff8241c532df3f3e570f9a5aeed6c853f888512ba4b75638d7c11c95ef6", size = 4712721, upload-time = "2026-05-18T19:17:40.512Z" }, - { url = "https://files.pythonhosted.org/packages/44/4d/1f44fd1d770b10dacbf6b5c6e520f4d6e0708744930f719dc04e67cab981/lxml-6.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5f6994074ebae6ffb04447268e37dc16edc304f9859cf91acb86e0af6c1b395c", size = 5252549, upload-time = "2026-05-18T19:17:51.236Z" }, - { url = "https://files.pythonhosted.org/packages/64/5d/1d66b84f850089254c230ef6ea6b267a5a54e2e179a5d960036a05d501d7/lxml-6.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80c2dfadb855da477cf73373ad29a333535dedb9b12bad02c9814c8e2b43bf08", size = 5226877, upload-time = "2026-05-18T19:18:00.875Z" }, - { url = "https://files.pythonhosted.org/packages/ad/00/84c4b5302d42a2d0184f38d538c8a197f33b52a50bd4f7bcfe990bce3036/lxml-6.1.1-cp311-cp311-win32.whl", hash = "sha256:30a89d3ac8faec007453fb541f3f46807eeec88edd5826f6e3fe001752a2c621", size = 3594072, upload-time = "2026-05-18T19:17:12.714Z" }, - { url = "https://files.pythonhosted.org/packages/61/9d/2e2f7d876349f45e0f3e29f72da311668853d59b58d473a2dea4f0160135/lxml-6.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:abbefa31eee84842140f67acef1c828e28bba8bbf0c3bc6e5492a9af88152c28", size = 4025469, upload-time = "2026-05-18T19:17:50.566Z" }, - { url = "https://files.pythonhosted.org/packages/b0/d5/570e6390e4110331e6208b2ba83d1482cc9146808ee118b22824a34c1070/lxml-6.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:dcb292aa7fe485ceff7af4f92e46c5af397daec5dff64871a528f0fc47a3cc5b", size = 3667640, upload-time = "2026-05-19T19:22:48.293Z" }, - { url = "https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7", size = 8570821, upload-time = "2026-05-18T19:17:42.068Z" }, - { url = "https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1", size = 4624252, upload-time = "2026-05-18T19:17:47.897Z" }, - { url = "https://files.pythonhosted.org/packages/42/95/bb63f0fd62e554fe078e1fb3c8fe9083c14ddc7ad7fa178d10e57e071ac7/lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc", size = 4930746, upload-time = "2026-05-18T19:18:29.637Z" }, - { url = "https://files.pythonhosted.org/packages/eb/99/0013e8d9b5960f4f041cf0b73e2f80c23eb5205b1f7bfb20203243651359/lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc", size = 5093723, upload-time = "2026-05-18T19:18:34.168Z" }, - { url = "https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383", size = 5005557, upload-time = "2026-05-18T19:18:39.798Z" }, - { url = "https://files.pythonhosted.org/packages/42/2f/cc9bf06afe70f9c9093ae60855d9759da9db601ec4080f7473319666ffd7/lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b", size = 5631036, upload-time = "2026-05-18T19:18:44.858Z" }, - { url = "https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818", size = 5240367, upload-time = "2026-05-18T19:18:49.217Z" }, - { url = "https://files.pythonhosted.org/packages/78/83/8555d40948b09ce86f1bd0c68a7ac31d07b1929f92cc1b074006c97ef2d2/lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740", size = 5350171, upload-time = "2026-05-18T19:18:52.779Z" }, - { url = "https://files.pythonhosted.org/packages/63/75/5d92da93729b7bad783689e6496049fa40927b45bec7bf183c981de3ca70/lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f", size = 4694874, upload-time = "2026-05-18T19:18:55.139Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b5/3aad415a9a25b822e783f15deeb4dffccf5113030f1afa2222dd929313d9/lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2", size = 5244492, upload-time = "2026-05-18T19:19:01.28Z" }, - { url = "https://files.pythonhosted.org/packages/f1/a1/5fcf7eb9904b80086aa47dcf0027de07b1bb990afad2e6823144c368ae04/lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635", size = 5048232, upload-time = "2026-05-18T19:18:12.67Z" }, - { url = "https://files.pythonhosted.org/packages/77/74/1f601b63c7a69fcdf10fa9b148c81da8442204194f6c55509cc485c786b9/lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf", size = 4777023, upload-time = "2026-05-18T19:18:15.928Z" }, - { url = "https://files.pythonhosted.org/packages/a2/b9/7a78f51aec95b1bf780d78e12705a9f6533284f8693dc5c0e6724fa53d3f/lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc", size = 5645773, upload-time = "2026-05-18T19:18:23.223Z" }, - { url = "https://files.pythonhosted.org/packages/a5/6e/98a7b7ad54e4e74fa1f20fff776913980619d0ebe5558232d7da6580bdd8/lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955", size = 5233088, upload-time = "2026-05-18T19:18:31.433Z" }, - { url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995, upload-time = "2026-05-18T19:18:37.091Z" }, - { url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382, upload-time = "2026-05-18T19:17:18.37Z" }, - { url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255, upload-time = "2026-05-18T19:17:56.781Z" }, - { url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610, upload-time = "2026-05-19T19:22:50.843Z" }, - { url = "https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736", size = 8559780, upload-time = "2026-05-18T19:17:57.661Z" }, - { url = "https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9", size = 4618006, upload-time = "2026-05-18T19:18:04.452Z" }, - { url = "https://files.pythonhosted.org/packages/6e/ca/ab7bfe2bf4c972af5e7878262845ead3a24a929a9b04bc11c7c1ece6c82a/lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354", size = 4924139, upload-time = "2026-05-18T19:19:04.873Z" }, - { url = "https://files.pythonhosted.org/packages/6b/55/a0c72851dfee5ecc689f949723a73dea457758912542cb955b108eaf0d8f/lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca", size = 5082329, upload-time = "2026-05-18T19:19:09.728Z" }, - { url = "https://files.pythonhosted.org/packages/f0/b6/0608f7d61a3b96cc67e5648a3d906e31a5082093e10e7be65b3886289938/lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099", size = 4993564, upload-time = "2026-05-18T19:19:13.608Z" }, - { url = "https://files.pythonhosted.org/packages/4c/66/ae227524b066d29d55bf0b453d93d2d793c40218657d643dcbbca13b8faf/lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6", size = 5613467, upload-time = "2026-05-18T19:19:16.228Z" }, - { url = "https://files.pythonhosted.org/packages/a6/76/dbe4a00b50385e40194231dcfe5a12c059de7cf90e89c83407d2b085b719/lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085", size = 5228304, upload-time = "2026-05-18T19:19:19.354Z" }, - { url = "https://files.pythonhosted.org/packages/1c/01/00b1b8442ed2041793336868ba0b9ea4b13d7da7c085c6404c207a63bf79/lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e", size = 5341607, upload-time = "2026-05-18T19:19:22.297Z" }, - { url = "https://files.pythonhosted.org/packages/63/36/1ad29931e9a4638bb707869f01d423a6c815f82152138d1a40dfcfde2b95/lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f", size = 4700168, upload-time = "2026-05-18T19:19:25.133Z" }, - { url = "https://files.pythonhosted.org/packages/3c/d1/a9536cecf9be18a0dc72d32bead283a2332d1ffebd2dd3ac70ce444686e5/lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c", size = 5232487, upload-time = "2026-05-18T19:19:28.603Z" }, - { url = "https://files.pythonhosted.org/packages/0e/77/b4fb1e03bf5d130e879214d3100092e386418807fb74dd0adc4b0a48f351/lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b", size = 5044231, upload-time = "2026-05-18T19:18:42.246Z" }, - { url = "https://files.pythonhosted.org/packages/26/4c/d00daeeb0a5530c4028a9232aa1b93db3ef4ed2158c116ea73c79a9765b3/lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2", size = 4769450, upload-time = "2026-05-18T19:18:48.013Z" }, - { url = "https://files.pythonhosted.org/packages/ed/6a/715a3a8d156ce42f29cf014706f5410c2ff3b02267774110fc23266409fe/lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5", size = 5635874, upload-time = "2026-05-18T19:18:51.914Z" }, - { url = "https://files.pythonhosted.org/packages/45/37/0544bc21dde2a88f3a17b504e6fc79c0e01d25a33c2f6079724e9e72b9c7/lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785", size = 5223987, upload-time = "2026-05-18T19:18:59.715Z" }, - { url = "https://files.pythonhosted.org/packages/4d/f8/f6a5e8185bcb28c2befae3d31f8e3df3b811cb0f47746517a81279fcafe1/lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947", size = 5250276, upload-time = "2026-05-18T19:19:03.834Z" }, - { url = "https://files.pythonhosted.org/packages/c7/f2/1a2b9f1b7a49d45495369be7ef9ad05b262930f2eab3e3145706fca8083f/lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca", size = 3596903, upload-time = "2026-05-18T19:17:29.863Z" }, - { url = "https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660", size = 3995869, upload-time = "2026-05-18T19:18:02.596Z" }, - { url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490, upload-time = "2026-05-19T19:22:53.846Z" }, - { url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" }, - { url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" }, - { url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" }, - { url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" }, - { url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" }, - { url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" }, - { url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" }, - { url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" }, - { url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" }, - { url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" }, - { url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" }, - { url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" }, - { url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" }, - { url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" }, - { url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" }, - { url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" }, - { url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" }, - { url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" }, - { url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" }, - { url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" }, - { url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" }, - { url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" }, - { url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" }, - { url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" }, - { url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" }, - { url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" }, - { url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" }, - { url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" }, - { url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" }, - { url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" }, - { url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" }, - { url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" }, - { url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" }, - { url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" }, - { url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" }, - { url = "https://files.pythonhosted.org/packages/b5/32/86a3f0f724a3a402d4627937a7fc27b160e45e7012b4adf47f6e1e844511/lxml-6.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:31033dc34636ea6b7d5cc11b1ddbda78a14de858ba9d3e1ed4b69a3085bc521e", size = 3930127, upload-time = "2026-05-18T19:19:02.27Z" }, - { url = "https://files.pythonhosted.org/packages/40/44/d832e82af08723761556d004b1d04d281c09f9a8cecd7d3148548c9941a3/lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3893c14c4b6ac5b2d54ba8cf03e99fe5104e592de491f19bd6b82756c09f8004", size = 4210769, upload-time = "2026-05-18T19:20:41.427Z" }, - { url = "https://files.pythonhosted.org/packages/6d/39/0dc5949f759ed7d951e0bb8c2f2d9d7aca1908d22352fa84a8afd2ea54af/lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c07da4cebf6889f03ebac8d238f62318e29f495de0aa18a51ea14e61ae907e2e", size = 4318163, upload-time = "2026-05-18T19:20:44.702Z" }, - { url = "https://files.pythonhosted.org/packages/e6/fb/8ab3845fe046ba4cbf74536bcf6801a774b7caf4350de1c5d37f1f0a9e90/lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6f0ce10945fab9c4c06ce14e22af9059d1a87493a9af4501a5b0b9187e21cf2", size = 4250945, upload-time = "2026-05-18T19:20:47.385Z" }, - { url = "https://files.pythonhosted.org/packages/68/1b/7553ab136894374ffae8851ec06f98f511cd8e66246e41b6be059d0a7289/lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8844cd288697c6425c9beba919302241e3278871dc6519515e72b04e987abcf", size = 4401664, upload-time = "2026-05-18T19:20:50.489Z" }, - { url = "https://files.pythonhosted.org/packages/db/a4/441aee36c6f6b249823d20fd91f9be9ab89d7c5a8ae542a4a4ca6d342d56/lxml-6.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ed21202aec73cda4d55d1ce57b389aadb90ffb044e6cd1080b8347efe1b1ec84", size = 3508989, upload-time = "2026-05-18T19:18:38.158Z" }, -] - [[package]] name = "markdown-it-py" version = "4.2.0" @@ -2625,96 +1798,6 @@ 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.optional-dependencies] -linkify = [ - { name = "linkify-it-py" }, -] - -[[package]] -name = "markupsafe" -version = "3.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, - { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, - { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, - { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, - { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, - { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, - { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, - { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, - { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, - { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, - { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, - { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, - { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, - { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, - { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, - { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, - { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, - { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, - { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, - { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, - { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, - { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, - { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, - { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, - { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, - { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, - { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, - { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, - { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, - { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, - { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, - { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, - { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, - { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, - { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, - { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, - { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, - { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, - { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, - { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, - { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, - { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, - { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, - { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, - { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, - { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, - { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, - { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, - { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, - { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, - { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, - { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, - { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, - { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, - { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, - { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, - { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, -] - [[package]] name = "mcp" version = "1.26.0" @@ -2740,18 +1823,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, ] -[[package]] -name = "mdit-py-plugins" -version = "0.6.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, -] - [[package]] name = "mdurl" version = "0.1.2" @@ -2761,120 +1832,6 @@ 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 = "mmh3" -version = "5.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/91/1a/edb23803a168f070ded7a3014c6d706f63b90c84ccc024f89d794a3b7a6d/mmh3-5.2.1.tar.gz", hash = "sha256:bbea5b775f0ac84945191fb83f845a6fd9a21a03ea7f2e187defac7e401616ad", size = 33775, upload-time = "2026-03-05T15:55:57.716Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/bb/88ee54afa5644b0f35ab5b435f208394feb963e5bb47c4e404deb625ffa4/mmh3-5.2.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5d87a3584093e1a89987e3d36d82c98d9621b2cb944e22a420aa1401e096758f", size = 56080, upload-time = "2026-03-05T15:53:40.452Z" }, - { url = "https://files.pythonhosted.org/packages/cc/bf/5404c2fd6ac84819e8ff1b7e34437b37cf55a2b11318894909e7bb88de3f/mmh3-5.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:30e4d2084df019880d55f6f7bea35328d9b464ebee090baa372c096dc77556fb", size = 40462, upload-time = "2026-03-05T15:53:41.751Z" }, - { url = "https://files.pythonhosted.org/packages/de/0b/52bffad0b52ae4ea53e222b594bd38c08ecac1fc410323220a7202e43da5/mmh3-5.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0bbc17250b10d3466875a40a52520a6bac3c02334ca709207648abd3c223ed5c", size = 40077, upload-time = "2026-03-05T15:53:42.753Z" }, - { url = "https://files.pythonhosted.org/packages/a0/9e/326c93d425b9fa4cbcdc71bc32aaba520db37577d632a24d25d927594eca/mmh3-5.2.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:76219cd1eefb9bf4af7856e3ae563d15158efa145c0aab01e9933051a1954045", size = 95302, upload-time = "2026-03-05T15:53:43.867Z" }, - { url = "https://files.pythonhosted.org/packages/c6/b1/e20d5f0d19c4c0f3df213fa7dcfa0942c4fb127d38e11f398ae8ddf6cccc/mmh3-5.2.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb9d44c25244e11c8be3f12c938ca8ba8404620ef8092245d2093c6ab3df260f", size = 101174, upload-time = "2026-03-05T15:53:45.194Z" }, - { url = "https://files.pythonhosted.org/packages/7f/4a/1a9bb3e33c18b1e1cee2c249a3053c4d4d9c93ecb30738f39a62249a7e86/mmh3-5.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d5d542bf2abd0fd0361e8017d03f7cb5786214ceb4a40eef1539d6585d93386", size = 103979, upload-time = "2026-03-05T15:53:46.334Z" }, - { url = "https://files.pythonhosted.org/packages/ff/8d/dab9ee7545429e7acdd38d23d0104471d31de09a0c695f1b751e0ff34532/mmh3-5.2.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:08043f7cb1fb9467c3fbbbaea7896986e7fbc81f4d3fd9289a73d9110ab6207a", size = 110898, upload-time = "2026-03-05T15:53:47.443Z" }, - { url = "https://files.pythonhosted.org/packages/72/08/408f11af7fe9e76b883142bb06536007cc7f237be2a5e9ad4e837716e627/mmh3-5.2.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:add7ac388d1e0bf57259afbcf9ed05621a3bf11ce5ee337e7536f1e1aaf056b0", size = 118308, upload-time = "2026-03-05T15:53:49.1Z" }, - { url = "https://files.pythonhosted.org/packages/86/2d/0551be7fe0000736d9ad12ffa1f130d7a0c17b49193d6dc41c82bd9404c6/mmh3-5.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:41105377f6282e8297f182e393a79cfffd521dde37ace52b106373bdcd9ca5cb", size = 101671, upload-time = "2026-03-05T15:53:50.317Z" }, - { url = "https://files.pythonhosted.org/packages/44/17/6e4f80c4e6ad590139fa2017c3aeca54e7cc9ef68e08aa142a0c90f40a97/mmh3-5.2.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3cb61db880ec11e984348227b333259994c2c85caa775eb7875decb3768db890", size = 96682, upload-time = "2026-03-05T15:53:51.48Z" }, - { url = "https://files.pythonhosted.org/packages/ad/a7/b82fccd38c1fa815de72e94ebe9874562964a10e21e6c1bc3b01d3f15a0e/mmh3-5.2.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e8b5378de2b139c3a830f0209c1e91f7705919a4b3e563a10955104f5097a70a", size = 110287, upload-time = "2026-03-05T15:53:52.68Z" }, - { url = "https://files.pythonhosted.org/packages/a8/a1/2644069031c8cec0be46f0346f568a53f42fddd843f03cc890306699c1e2/mmh3-5.2.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e904f2417f0d6f6d514f3f8b836416c360f306ddaee1f84de8eef1e722d212e5", size = 111899, upload-time = "2026-03-05T15:53:53.791Z" }, - { url = "https://files.pythonhosted.org/packages/51/7b/6614f3eb8fb33f931fa7616c6d477247e48ec6c5082b02eeeee998cffa94/mmh3-5.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f1fbb0a99125b1287c6d9747f937dc66621426836d1a2d50d05aecfc81911b57", size = 100078, upload-time = "2026-03-05T15:53:55.234Z" }, - { url = "https://files.pythonhosted.org/packages/27/9a/dd4d5a5fb893e64f71b42b69ecae97dd78db35075412488b24036bc5599c/mmh3-5.2.1-cp310-cp310-win32.whl", hash = "sha256:b4cce60d0223074803c9dbe0721ad3fa51dafe7d462fee4b656a1aa01ee07518", size = 40756, upload-time = "2026-03-05T15:53:56.319Z" }, - { url = "https://files.pythonhosted.org/packages/c9/34/0b25889450f8aeffcec840aa73251e853f059c1b72ed1d1c027b956f95f5/mmh3-5.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:6f01f044112d43a20be2f13a11683666d87151542ad627fe41a18b9791d2802f", size = 41519, upload-time = "2026-03-05T15:53:57.41Z" }, - { url = "https://files.pythonhosted.org/packages/fd/31/8fd42e3c526d0bcb1db7f569c0de6729e180860a0495e387a53af33c2043/mmh3-5.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:7501e9be34cb21e72fcfe672aafd0eee65c16ba2afa9dcb5500a587d3a0580f0", size = 39285, upload-time = "2026-03-05T15:53:58.697Z" }, - { url = "https://files.pythonhosted.org/packages/65/d7/3312a59df3c1cdd783f4cf0c4ee8e9decff9c5466937182e4cc7dbbfe6c5/mmh3-5.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dae0f0bd7d30c0ad61b9a504e8e272cb8391eed3f1587edf933f4f6b33437450", size = 56082, upload-time = "2026-03-05T15:53:59.702Z" }, - { url = "https://files.pythonhosted.org/packages/61/96/6f617baa098ca0d2989bfec6d28b5719532cd8d8848782662f5b755f657f/mmh3-5.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9aeaf53eaa075dd63e81512522fd180097312fb2c9f476333309184285c49ce0", size = 40458, upload-time = "2026-03-05T15:54:01.548Z" }, - { url = "https://files.pythonhosted.org/packages/c1/b4/9cd284bd6062d711e13d26c04d4778ab3f690c1c38a4563e3c767ec8802e/mmh3-5.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0634581290e6714c068f4aa24020acf7880927d1f0084fa753d9799ae9610082", size = 40079, upload-time = "2026-03-05T15:54:02.743Z" }, - { url = "https://files.pythonhosted.org/packages/f6/09/a806334ce1d3d50bf782b95fcee8b3648e1e170327d4bb7b4bad2ad7d956/mmh3-5.2.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e080c0637aea036f35507e803a4778f119a9b436617694ae1c5c366805f1e997", size = 97242, upload-time = "2026-03-05T15:54:04.536Z" }, - { url = "https://files.pythonhosted.org/packages/ee/93/723e317dd9e041c4dc4566a2eb53b01ad94de31750e0b834f1643905e97c/mmh3-5.2.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db0562c5f71d18596dcd45e854cf2eeba27d7543e1a3acdafb7eef728f7fe85d", size = 103082, upload-time = "2026-03-05T15:54:06.387Z" }, - { url = "https://files.pythonhosted.org/packages/61/b5/f96121e69cc48696075071531cf574f112e1ffd08059f4bffb41210e6fc5/mmh3-5.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d9f9a3ce559a5267014b04b82956993270f63ec91765e13e9fd73daf2d2738e", size = 106054, upload-time = "2026-03-05T15:54:07.506Z" }, - { url = "https://files.pythonhosted.org/packages/82/49/192b987ec48d0b2aecf8ac285a9b11fbc00030f6b9c694664ae923458dde/mmh3-5.2.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:960b1b3efa39872ac8b6cc3a556edd6fb90ed74f08c9c45e028f1005b26aa55d", size = 112910, upload-time = "2026-03-05T15:54:09.403Z" }, - { url = "https://files.pythonhosted.org/packages/cf/a1/03e91fd334ed0144b83343a76eb11f17434cd08f746401488cfeafb2d241/mmh3-5.2.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d30b650595fdbe32366b94cb14f30bb2b625e512bd4e1df00611f99dc5c27fd4", size = 120551, upload-time = "2026-03-05T15:54:10.587Z" }, - { url = "https://files.pythonhosted.org/packages/93/b9/b89a71d2ff35c3a764d1c066c7313fc62c7cc48fa48a4b3b0304a4a0146f/mmh3-5.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:82f3802bfc4751f420d591c5c864de538b71cea117fce67e4595c2afede08a15", size = 99096, upload-time = "2026-03-05T15:54:11.76Z" }, - { url = "https://files.pythonhosted.org/packages/36/b5/613772c1c6ed5f7b63df55eb131e887cc43720fec392777b95a79d34e640/mmh3-5.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:915e7a2418f10bd1151b1953df06d896db9783c9cfdb9a8ee1f9b3a4331ab503", size = 98524, upload-time = "2026-03-05T15:54:13.122Z" }, - { url = "https://files.pythonhosted.org/packages/5e/0e/1524566fe8eaf871e4f7bc44095929fcd2620488f402822d848df19d679c/mmh3-5.2.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fc78739b5ec6e4fb02301984a3d442a91406e7700efbe305071e7fd1c78278f2", size = 106239, upload-time = "2026-03-05T15:54:14.601Z" }, - { url = "https://files.pythonhosted.org/packages/04/94/21adfa7d90a7a697137ad6de33eeff6445420ca55e433a5d4919c79bc3b5/mmh3-5.2.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:41aac7002a749f08727cb91babff1daf8deac317c0b1f317adc69be0e6c375d1", size = 109797, upload-time = "2026-03-05T15:54:15.819Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e6/1aacc3a219e1aa62fa65669995d4a3562b35be5200ec03680c7e4bec9676/mmh3-5.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9d8089d853c7963a8ce87fff93e2a67075c0bc08684a08ea6ad13577c38ffc38", size = 97228, upload-time = "2026-03-05T15:54:16.992Z" }, - { url = "https://files.pythonhosted.org/packages/f1/b9/5e4cca8dcccf298add0a27f3c357bc8cf8baf821d35cdc6165e4bd5a48b0/mmh3-5.2.1-cp311-cp311-win32.whl", hash = "sha256:baeb47635cb33375dee4924cd93d7f5dcaa786c740b08423b0209b824a1ee728", size = 40751, upload-time = "2026-03-05T15:54:18.714Z" }, - { url = "https://files.pythonhosted.org/packages/72/fc/5b11d49247f499bcda591171e9cf3b6ee422b19e70aa2cef2e0ae65ca3b9/mmh3-5.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:1e4ecee40ba19e6975e1120829796770325841c2f153c0e9aecca927194c6a2a", size = 41517, upload-time = "2026-03-05T15:54:19.764Z" }, - { url = "https://files.pythonhosted.org/packages/8a/5f/2a511ee8a1c2a527c77726d5231685b72312c5a1a1b7639ad66a9652aa84/mmh3-5.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:c302245fd6c33d96bd169c7ccf2513c20f4c1e417c07ce9dce107c8bc3f8411f", size = 39287, upload-time = "2026-03-05T15:54:20.904Z" }, - { url = "https://files.pythonhosted.org/packages/92/94/bc5c3b573b40a328c4d141c20e399039ada95e5e2a661df3425c5165fd84/mmh3-5.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0cc21533878e5586b80d74c281d7f8da7932bc8ace50b8d5f6dbf7e3935f63f1", size = 56087, upload-time = "2026-03-05T15:54:21.92Z" }, - { url = "https://files.pythonhosted.org/packages/f6/80/64a02cc3e95c3af0aaa2590849d9ed24a9f14bb93537addde688e039b7c3/mmh3-5.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4eda76074cfca2787c8cf1bec603eaebdddd8b061ad5502f85cddae998d54f00", size = 40500, upload-time = "2026-03-05T15:54:22.953Z" }, - { url = "https://files.pythonhosted.org/packages/8b/72/e6d6602ce18adf4ddcd0e48f2e13590cc92a536199e52109f46f259d3c46/mmh3-5.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:eee884572b06bbe8a2b54f424dbd996139442cf83c76478e1ec162512e0dd2c7", size = 40034, upload-time = "2026-03-05T15:54:23.943Z" }, - { url = "https://files.pythonhosted.org/packages/59/c2/bf4537a8e58e21886ef16477041238cab5095c836496e19fafc34b7445d2/mmh3-5.2.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d0b7e803191db5f714d264044e06189c8ccd3219e936cc184f07106bd17fd7b", size = 97292, upload-time = "2026-03-05T15:54:25.335Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e2/51ed62063b44d10b06d975ac87af287729eeb5e3ed9772f7584a17983e90/mmh3-5.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e6c219e375f6341d0959af814296372d265a8ca1af63825f65e2e87c618f006", size = 103274, upload-time = "2026-03-05T15:54:26.44Z" }, - { url = "https://files.pythonhosted.org/packages/75/ce/12a7524dca59eec92e5b31fdb13ede1e98eda277cf2b786cf73bfbc24e81/mmh3-5.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26fb5b9c3946bf7f1daed7b37e0c03898a6f062149127570f8ede346390a0825", size = 106158, upload-time = "2026-03-05T15:54:28.578Z" }, - { url = "https://files.pythonhosted.org/packages/86/1f/d3ba6dd322d01ab5d44c46c8f0c38ab6bbbf9b5e20e666dfc05bf4a23604/mmh3-5.2.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3c38d142c706201db5b2345166eeef1e7740e3e2422b470b8ba5c8727a9b4c7a", size = 113005, upload-time = "2026-03-05T15:54:29.767Z" }, - { url = "https://files.pythonhosted.org/packages/b6/a9/15d6b6f913294ea41b44d901741298e3718e1cb89ee626b3694625826a43/mmh3-5.2.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50885073e2909251d4718634a191c49ae5f527e5e1736d738e365c3e8be8f22b", size = 120744, upload-time = "2026-03-05T15:54:30.931Z" }, - { url = "https://files.pythonhosted.org/packages/76/b3/70b73923fd0284c439860ff5c871b20210dfdbe9a6b9dd0ee6496d77f174/mmh3-5.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3f99e1756fc48ad507b95e5d86f2fb21b3d495012ff13e6592ebac14033f166", size = 99111, upload-time = "2026-03-05T15:54:32.353Z" }, - { url = "https://files.pythonhosted.org/packages/dd/38/99f7f75cd27d10d8b899a1caafb9d531f3903e4d54d572220e3d8ac35e89/mmh3-5.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62815d2c67f2dd1be76a253d88af4e1da19aeaa1820146dec52cf8bee2958b16", size = 98623, upload-time = "2026-03-05T15:54:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/fd/68/6e292c0853e204c44d2f03ea5f090be3317a0e2d9417ecb62c9eb27687df/mmh3-5.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8f767ba0911602ddef289404e33835a61168314ebd3c729833db2ed685824211", size = 106437, upload-time = "2026-03-05T15:54:35.177Z" }, - { url = "https://files.pythonhosted.org/packages/dd/c6/fedd7284c459cfb58721d461fcf5607a4c1f5d9ab195d113d51d10164d16/mmh3-5.2.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:67e41a497bac88cc1de96eeba56eeb933c39d54bc227352f8455aa87c4ca4000", size = 110002, upload-time = "2026-03-05T15:54:36.673Z" }, - { url = "https://files.pythonhosted.org/packages/3b/ac/ca8e0c19a34f5b71390171d2ff0b9f7f187550d66801a731bb68925126a4/mmh3-5.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d74a03fb57757ece25aa4b3c1c60157a1cece37a020542785f942e2f827eed5", size = 97507, upload-time = "2026-03-05T15:54:37.804Z" }, - { url = "https://files.pythonhosted.org/packages/df/94/6ebb9094cfc7ac5e7950776b9d13a66bb4a34f83814f32ba2abc9494fc68/mmh3-5.2.1-cp312-cp312-win32.whl", hash = "sha256:7374d6e3ef72afe49697ecd683f3da12f4fc06af2d75433d0580c6746d2fa025", size = 40773, upload-time = "2026-03-05T15:54:40.077Z" }, - { url = "https://files.pythonhosted.org/packages/5b/3c/cd3527198cf159495966551c84a5f36805a10ac17b294f41f67b83f6a4d6/mmh3-5.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:3a9fed49c6ce4ed7e73f13182760c65c816da006debe67f37635580dfb0fae00", size = 41560, upload-time = "2026-03-05T15:54:41.148Z" }, - { url = "https://files.pythonhosted.org/packages/15/96/6fe5ebd0f970a076e3ed5512871ce7569447b962e96c125528a2f9724470/mmh3-5.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfcb95d9a744e6e2827dfc66ad10e1020e0cac255eb7f85652832d5a264c2fc", size = 39313, upload-time = "2026-03-05T15:54:42.171Z" }, - { url = "https://files.pythonhosted.org/packages/25/a5/9daa0508a1569a54130f6198d5462a92deda870043624aa3ea72721aa765/mmh3-5.2.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:723b2681ed4cc07d3401bbea9c201ad4f2a4ca6ba8cddaff6789f715dd2b391e", size = 40832, upload-time = "2026-03-05T15:54:43.212Z" }, - { url = "https://files.pythonhosted.org/packages/0a/6b/3230c6d80c1f4b766dedf280a92c2241e99f87c1504ff74205ec8cebe451/mmh3-5.2.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:3619473a0e0d329fd4aec8075628f8f616be2da41605300696206d6f36920c3d", size = 41964, upload-time = "2026-03-05T15:54:44.204Z" }, - { url = "https://files.pythonhosted.org/packages/62/fb/648bfddb74a872004b6ee751551bfdda783fe6d70d2e9723bad84dbe5311/mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e48d4dbe0f88e53081da605ae68644e5182752803bbc2beb228cca7f1c4454d6", size = 39114, upload-time = "2026-03-05T15:54:45.205Z" }, - { url = "https://files.pythonhosted.org/packages/95/c2/ab7901f87af438468b496728d11264cb397b3574d41506e71b92128e0373/mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a482ac121de6973897c92c2f31defc6bafb11c83825109275cffce54bb64933f", size = 39819, upload-time = "2026-03-05T15:54:46.509Z" }, - { url = "https://files.pythonhosted.org/packages/2f/ed/6f88dda0df67de1612f2e130ffea34cf84aaee5bff5b0aff4dbff2babe34/mmh3-5.2.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:17fbb47f0885ace8327ce1235d0416dc86a211dcd8cc1e703f41523be32cfec8", size = 40330, upload-time = "2026-03-05T15:54:47.864Z" }, - { url = "https://files.pythonhosted.org/packages/3d/66/7516d23f53cdf90f43fce24ab80c28f45e6851d78b46bef8c02084edf583/mmh3-5.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d51fde50a77f81330523562e3c2734ffdca9c4c9e9d355478117905e1cfe16c6", size = 56078, upload-time = "2026-03-05T15:54:48.9Z" }, - { url = "https://files.pythonhosted.org/packages/bc/34/4d152fdf4a91a132cb226b671f11c6b796eada9ab78080fb5ce1e95adaab/mmh3-5.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19bbd3b841174ae6ed588536ab5e1b1fe83d046e668602c20266547298d939a9", size = 40498, upload-time = "2026-03-05T15:54:49.942Z" }, - { url = "https://files.pythonhosted.org/packages/d4/4c/8e3af1b6d85a299767ec97bd923f12b06267089c1472c27c1696870d1175/mmh3-5.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be77c402d5e882b6fbacfd90823f13da8e0a69658405a39a569c6b58fdb17b03", size = 40033, upload-time = "2026-03-05T15:54:50.994Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f2/966ea560e32578d453c9e9db53d602cbb1d0da27317e232afa7c38ceba11/mmh3-5.2.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fd96476f04db5ceba1cfa0f21228f67c1f7402296f0e73fee3513aa680ad237b", size = 97320, upload-time = "2026-03-05T15:54:52.072Z" }, - { url = "https://files.pythonhosted.org/packages/bb/0d/2c5f9893b38aeb6b034d1a44ecd55a010148054f6a516abe53b5e4057297/mmh3-5.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:707151644085dd0f20fe4f4b573d28e5130c4aaa5f587e95b60989c5926653b5", size = 103299, upload-time = "2026-03-05T15:54:53.569Z" }, - { url = "https://files.pythonhosted.org/packages/1c/fc/2ebaef4a4d4376f89761274dc274035ffd96006ab496b4ee5af9b08f21a9/mmh3-5.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3737303ca9ea0f7cb83028781148fcda4f1dac7821db0c47672971dabcf63593", size = 106222, upload-time = "2026-03-05T15:54:55.092Z" }, - { url = "https://files.pythonhosted.org/packages/57/09/ea7ffe126d0ba0406622602a2d05e1e1a6841cc92fc322eb576c95b27fad/mmh3-5.2.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2778fed822d7db23ac5008b181441af0c869455b2e7d001f4019636ac31b6fe4", size = 113048, upload-time = "2026-03-05T15:54:56.305Z" }, - { url = "https://files.pythonhosted.org/packages/85/57/9447032edf93a64aa9bef4d9aa596400b1756f40411890f77a284f6293ca/mmh3-5.2.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d57dea657357230cc780e13920d7fa7db059d58fe721c80020f94476da4ca0a1", size = 120742, upload-time = "2026-03-05T15:54:57.453Z" }, - { url = "https://files.pythonhosted.org/packages/53/82/a86cc87cc88c92e9e1a598fee509f0409435b57879a6129bf3b3e40513c7/mmh3-5.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:169e0d178cb59314456ab30772429a802b25d13227088085b0d49b9fe1533104", size = 99132, upload-time = "2026-03-05T15:54:58.583Z" }, - { url = "https://files.pythonhosted.org/packages/54/f7/6b16eb1b40ee89bb740698735574536bc20d6cdafc65ae702ea235578e05/mmh3-5.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7e4e1f580033335c6f76d1e0d6b56baf009d1a64d6a4816347e4271ba951f46d", size = 98686, upload-time = "2026-03-05T15:55:00.078Z" }, - { url = "https://files.pythonhosted.org/packages/e8/88/a601e9f32ad1410f438a6d0544298ea621f989bd34a0731a7190f7dec799/mmh3-5.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2bd9f19f7f1fcebd74e830f4af0f28adad4975d40d80620be19ffb2b2af56c9f", size = 106479, upload-time = "2026-03-05T15:55:01.532Z" }, - { url = "https://files.pythonhosted.org/packages/d6/5c/ce29ae3dfc4feec4007a437a1b7435fb9507532a25147602cd5b52be86db/mmh3-5.2.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c88653877aeb514c089d1b3d473451677b8b9a6d1497dbddf1ae7934518b06d2", size = 110030, upload-time = "2026-03-05T15:55:02.934Z" }, - { url = "https://files.pythonhosted.org/packages/13/30/ae444ef2ff87c805d525da4fa63d27cda4fe8a48e77003a036b8461cfd5c/mmh3-5.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fceef7fe67c81e1585198215e42ad3fdba3a25644beda8fbdaf85f4d7b93175a", size = 97536, upload-time = "2026-03-05T15:55:04.135Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f9/dc3787ee5c813cc27fe79f45ad4500d9b5437f23a7402435cc34e07c7718/mmh3-5.2.1-cp313-cp313-win32.whl", hash = "sha256:54b64fb2433bc71488e7a449603bf8bd31fbcf9cb56fbe1eb6d459e90b86c37b", size = 40769, upload-time = "2026-03-05T15:55:05.277Z" }, - { url = "https://files.pythonhosted.org/packages/43/67/850e0b5a1e97799822ebfc4ca0e8c6ece3ed8baf7dcdf64de817dfdda2ca/mmh3-5.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:cae6383181f1e345317742d2ddd88f9e7d2682fa4c9432e3a74e47d92dce0229", size = 41563, upload-time = "2026-03-05T15:55:06.283Z" }, - { url = "https://files.pythonhosted.org/packages/c0/cc/98c90b28e1da5458e19fbfaf4adb5289208d3bfccd45dd14eab216a2f0bb/mmh3-5.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:022aa1a528604e6c83d0a7705fdef0b5355d897a9e0fa3a8d26709ceaa06965d", size = 39310, upload-time = "2026-03-05T15:55:07.323Z" }, - { url = "https://files.pythonhosted.org/packages/63/b4/65bc1fb2bb7f83e91c30865023b1847cf89a5f237165575e8c83aa536584/mmh3-5.2.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:d771f085fcdf4035786adfb1d8db026df1eb4b41dac1c3d070d1e49512843227", size = 40794, upload-time = "2026-03-05T15:55:09.773Z" }, - { url = "https://files.pythonhosted.org/packages/c4/86/7168b3d83be8eb553897b1fac9da8bbb06568e5cfe555ffc329ebb46f59d/mmh3-5.2.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:7f196cd7910d71e9d9860da0ff7a77f64d22c1ad931f1dd18559a06e03109fc0", size = 41923, upload-time = "2026-03-05T15:55:10.924Z" }, - { url = "https://files.pythonhosted.org/packages/bf/9b/b653ab611c9060ce8ff0ba25c0226757755725e789292f3ca138a58082cd/mmh3-5.2.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b1f12bd684887a0a5d55e6363ca87056f361e45451105012d329b86ec19dbe0b", size = 39131, upload-time = "2026-03-05T15:55:11.961Z" }, - { url = "https://files.pythonhosted.org/packages/9b/b4/5a2e0d34ab4d33543f01121e832395ea510132ea8e52cdf63926d9d81754/mmh3-5.2.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d106493a60dcb4aef35a0fac85105e150a11cf8bc2b0d388f5a33272d756c966", size = 39825, upload-time = "2026-03-05T15:55:13.013Z" }, - { url = "https://files.pythonhosted.org/packages/bd/69/81699a8f39a3f8d368bec6443435c0c392df0d200ad915bf0d222b588e03/mmh3-5.2.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:44983e45310ee5b9f73397350251cdf6e63a466406a105f1d16cb5baa659270b", size = 40344, upload-time = "2026-03-05T15:55:14.026Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b3/71c8c775807606e8fd8acc5c69016e1caf3200d50b50b6dd4b40ce10b76c/mmh3-5.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:368625fb01666655985391dbad3860dc0ba7c0d6b9125819f3121ee7292b4ac8", size = 56291, upload-time = "2026-03-05T15:55:15.137Z" }, - { url = "https://files.pythonhosted.org/packages/6f/75/2c24517d4b2ce9e4917362d24f274d3d541346af764430249ddcc4cb3a08/mmh3-5.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:72d1cc63bcc91e14933f77d51b3df899d6a07d184ec515ea7f56bff659e124d7", size = 40575, upload-time = "2026-03-05T15:55:16.518Z" }, - { url = "https://files.pythonhosted.org/packages/bf/b9/e4a360164365ac9f07a25f0f7928e3a66eb9ecc989384060747aa170e6aa/mmh3-5.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e8b4b5580280b9265af3e0409974fb79c64cf7523632d03fbf11df18f8b0181e", size = 40052, upload-time = "2026-03-05T15:55:17.735Z" }, - { url = "https://files.pythonhosted.org/packages/97/ca/120d92223a7546131bbbc31c9174168ee7a73b1366f5463ffe69d9e691fe/mmh3-5.2.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4cbbde66f1183db040daede83dd86c06d663c5bb2af6de1142b7c8c37923dd74", size = 97311, upload-time = "2026-03-05T15:55:18.959Z" }, - { url = "https://files.pythonhosted.org/packages/b6/71/c1a60c1652b8813ef9de6d289784847355417ee0f2980bca002fe87f4ae5/mmh3-5.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ff038d52ef6aa0f309feeba00c5095c9118d0abf787e8e8454d6048db2037fc", size = 103279, upload-time = "2026-03-05T15:55:20.448Z" }, - { url = "https://files.pythonhosted.org/packages/48/29/ad97f4be1509cdcb28ae32c15593ce7c415db47ace37f8fad35b493faa9a/mmh3-5.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4130d0b9ce5fad6af07421b1aecc7e079519f70d6c05729ab871794eded8617", size = 106290, upload-time = "2026-03-05T15:55:21.6Z" }, - { url = "https://files.pythonhosted.org/packages/77/29/1f86d22e281bd8827ba373600a4a8b0c0eae5ca6aa55b9a8c26d2a34decc/mmh3-5.2.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e0bfe77d238308839699944164b96a2eeccaf55f2af400f54dc20669d8d5f2", size = 113116, upload-time = "2026-03-05T15:55:22.826Z" }, - { url = "https://files.pythonhosted.org/packages/a7/7c/339971ea7ed4c12d98f421f13db3ea576a9114082ccb59d2d1a0f00ccac1/mmh3-5.2.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f963eafc0a77a6c0562397da004f5876a9bcf7265a7bcc3205e29636bc4a1312", size = 120740, upload-time = "2026-03-05T15:55:24.3Z" }, - { url = "https://files.pythonhosted.org/packages/e4/92/3c7c4bdb8e926bb3c972d1e2907d77960c1c4b250b41e8366cf20c6e4373/mmh3-5.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:92883836caf50d5255be03d988d75bc93e3f86ba247b7ca137347c323f731deb", size = 99143, upload-time = "2026-03-05T15:55:25.456Z" }, - { url = "https://files.pythonhosted.org/packages/df/0a/33dd8706e732458c8375eae63c981292de07a406bad4ec03e5269654aa2c/mmh3-5.2.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:57b52603e89355ff318025dd55158f6e71396c0f1f609d548e9ea9c94cc6ce0a", size = 98703, upload-time = "2026-03-05T15:55:26.723Z" }, - { url = "https://files.pythonhosted.org/packages/51/04/76bbce05df76cbc3d396f13b2ea5b1578ef02b6a5187e132c6c33f99d596/mmh3-5.2.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f40a95186a72fa0b67d15fef0f157bfcda00b4f59c8a07cbe5530d41ac35d105", size = 106484, upload-time = "2026-03-05T15:55:28.214Z" }, - { url = "https://files.pythonhosted.org/packages/d3/8f/c6e204a2c70b719c1f62ffd9da27aef2dddcba875ea9c31ca0e87b975a46/mmh3-5.2.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:58370d05d033ee97224c81263af123dea3d931025030fd34b61227a768a8858a", size = 110012, upload-time = "2026-03-05T15:55:29.532Z" }, - { url = "https://files.pythonhosted.org/packages/e3/37/7181efd8e39db386c1ebc3e6b7d1f702a09d7c1197a6f2742ed6b5c16597/mmh3-5.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7be6dfb49e48fd0a7d91ff758a2b51336f1cd21f9d44b20f6801f072bd080cdd", size = 97508, upload-time = "2026-03-05T15:55:31.01Z" }, - { url = "https://files.pythonhosted.org/packages/42/0f/afa7ca2615fd85e1469474bb860e381443d0b868c083b62b41cb1d7ca32f/mmh3-5.2.1-cp314-cp314-win32.whl", hash = "sha256:54fe8518abe06a4c3852754bfd498b30cc58e667f376c513eac89a244ce781a4", size = 41387, upload-time = "2026-03-05T15:55:32.403Z" }, - { url = "https://files.pythonhosted.org/packages/71/0d/46d42a260ee1357db3d486e6c7a692e303c017968e14865e00efa10d09fc/mmh3-5.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3f796b535008708846044c43302719c6956f39ca2d93f2edda5319e79a29efbb", size = 42101, upload-time = "2026-03-05T15:55:33.646Z" }, - { url = "https://files.pythonhosted.org/packages/a4/7b/848a8378059d96501a41159fca90d6a99e89736b0afbe8e8edffeac8c74b/mmh3-5.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:cd471ede0d802dd936b6fab28188302b2d497f68436025857ca72cd3810423fe", size = 39836, upload-time = "2026-03-05T15:55:35.026Z" }, - { url = "https://files.pythonhosted.org/packages/27/61/1dabea76c011ba8547c25d30c91c0ec22544487a8750997a27a0c9e1180b/mmh3-5.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5174a697ce042fa77c407e05efe41e03aa56dae9ec67388055820fb48cf4c3ba", size = 57727, upload-time = "2026-03-05T15:55:36.162Z" }, - { url = "https://files.pythonhosted.org/packages/b7/32/731185950d1cf2d5e28979cc8593016ba1619a295faba10dda664a4931b5/mmh3-5.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0a3984146e414684a6be2862d84fcb1035f4984851cb81b26d933bab6119bf00", size = 41308, upload-time = "2026-03-05T15:55:37.254Z" }, - { url = "https://files.pythonhosted.org/packages/76/aa/66c76801c24b8c9418b4edde9b5e57c75e72c94e29c48f707e3962534f18/mmh3-5.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bd6e7d363aa93bd3421b30b6af97064daf47bc96005bddba67c5ffbc6df426b8", size = 40758, upload-time = "2026-03-05T15:55:38.61Z" }, - { url = "https://files.pythonhosted.org/packages/9e/bb/79a1f638a02f0ae389f706d13891e2fbf7d8c0a22ecde67ba828951bb60a/mmh3-5.2.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:113f78e7463a36dbbcea05bfe688efd7fa759d0f0c56e73c974d60dcfec3dfcc", size = 109670, upload-time = "2026-03-05T15:55:40.13Z" }, - { url = "https://files.pythonhosted.org/packages/26/94/8cd0e187a288985bcfc79bf5144d1d712df9dee74365f59d26e3a1865be6/mmh3-5.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e8ec5f606e0809426d2440e0683509fb605a8820a21ebd120dcdba61b74ef7f", size = 117399, upload-time = "2026-03-05T15:55:42.076Z" }, - { url = "https://files.pythonhosted.org/packages/42/94/dfea6059bd5c5beda565f58a4096e43f4858fb6d2862806b8bbd12cbb284/mmh3-5.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22b0f9971ec4e07e8223f2beebe96a6cfc779d940b6f27d26604040dd74d3a44", size = 120386, upload-time = "2026-03-05T15:55:43.481Z" }, - { url = "https://files.pythonhosted.org/packages/47/cb/f9c45e62aaa67220179f487772461d891bb582bb2f9783c944832c60efd9/mmh3-5.2.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:85ffc9920ffc39c5eee1e3ac9100c913a0973996fbad5111f939bbda49204bb7", size = 125924, upload-time = "2026-03-05T15:55:44.638Z" }, - { url = "https://files.pythonhosted.org/packages/a5/83/fe54a4a7c11bc9f623dfc1707decd034245602b076dfc1dcc771a4163170/mmh3-5.2.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7aec798c2b01aaa65a55f1124f3405804184373abb318a3091325aece235f67c", size = 135280, upload-time = "2026-03-05T15:55:45.866Z" }, - { url = "https://files.pythonhosted.org/packages/97/67/fe7e9e9c143daddd210cd22aef89cbc425d58ecf238d2b7d9eb0da974105/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55dbbd8ffbc40d1697d5e2d0375b08599dae8746b0b08dea05eee4ce81648fac", size = 110050, upload-time = "2026-03-05T15:55:47.074Z" }, - { url = "https://files.pythonhosted.org/packages/43/c4/6d4b09fcbef80794de447c9378e39eefc047156b290fa3dd2d5257ca8227/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6c85c38a279ca9295a69b9b088a2e48aa49737bb1b34e6a9dc6297c110e8d912", size = 111158, upload-time = "2026-03-05T15:55:48.239Z" }, - { url = "https://files.pythonhosted.org/packages/81/a6/ca51c864bdb30524beb055a6d8826db3906af0834ec8c41d097a6e8573d5/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6290289fa5fb4c70fd7f72016e03633d60388185483ff3b162912c81205ae2cf", size = 116890, upload-time = "2026-03-05T15:55:49.405Z" }, - { url = "https://files.pythonhosted.org/packages/cc/04/5a1fe2e2ad843d03e89af25238cbc4f6840a8bb6c4329a98ab694c71deda/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4fc6cd65dc4d2fdb2625e288939a3566e36127a84811a4913f02f3d5931da52d", size = 123121, upload-time = "2026-03-05T15:55:50.61Z" }, - { url = "https://files.pythonhosted.org/packages/af/4d/3c820c6f4897afd25905270a9f2330a23f77a207ea7356f7aadace7273c0/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:623f938f6a039536cc02b7582a07a080f13fdfd48f87e63201d92d7e34d09a18", size = 110187, upload-time = "2026-03-05T15:55:52.143Z" }, - { url = "https://files.pythonhosted.org/packages/21/54/1d71cd143752361c0aebef16ad3f55926a6faf7b112d355745c1f8a25f7f/mmh3-5.2.1-cp314-cp314t-win32.whl", hash = "sha256:29bc3973676ae334412efdd367fcd11d036b7be3efc1ce2407ef8676dabfeb82", size = 41934, upload-time = "2026-03-05T15:55:53.564Z" }, - { url = "https://files.pythonhosted.org/packages/9d/e4/63a2a88f31d93dea03947cccc2a076946857e799ea4f7acdecbf43b324aa/mmh3-5.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:28cfab66577000b9505a0d068c731aee7ca85cd26d4d63881fab17857e0fe1fb", size = 43036, upload-time = "2026-03-05T15:55:55.252Z" }, - { url = "https://files.pythonhosted.org/packages/a0/0f/59204bf136d1201f8d7884cfbaf7498c5b4674e87a4c693f9bde63741ce1/mmh3-5.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dfd51b4c56b673dfbc43d7d27ef857dd91124801e2806c69bb45585ce0fa019b", size = 40391, upload-time = "2026-03-05T15:55:56.697Z" }, -] - [[package]] name = "more-itertools" version = "11.1.0" @@ -2884,15 +1841,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, ] -[[package]] -name = "mpmath" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, -] - [[package]] name = "multidict" version = "6.7.1" @@ -3099,42 +2047,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] -[[package]] -name = "narwhals" -version = "2.22.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/62/3c/c4ef2164a71c1a63d7f1ae411c4082c5fa872405106db60a4b7114989ad7/narwhals-2.22.1.tar.gz", hash = "sha256:d62920805a0a43b7ff8b54b0c0d3142d796f8a9301836ada37e573d6a33cbcd9", size = 647493, upload-time = "2026-06-05T12:34:34.051Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/48/ca/36339329c4604adbcc99c899b7eb1ce1a555c499b6a6860757dc9bfed36d/narwhals-2.22.1-py3-none-any.whl", hash = "sha256:60567d774edf77db53906f89d9fbd164e66e56d66d388e1e6990f17ac33cfb53", size = 454815, upload-time = "2026-06-05T12:34:32.289Z" }, -] - -[[package]] -name = "networkx" -version = "3.4.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368, upload-time = "2024-10-21T12:39:38.695Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f", size = 1723263, upload-time = "2024-10-21T12:39:36.247Z" }, -] - -[[package]] -name = "networkx" -version = "3.6.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version == '3.14.*'", - "python_full_version == '3.13.*'", - "python_full_version >= '3.11' and python_full_version < '3.13'", -] -sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, -] - [[package]] name = "num2words" version = "0.5.14" @@ -3297,282 +2209,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, ] -[[package]] -name = "nvidia-cublas" -version = "13.1.1.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nvidia-cuda-nvrtc" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, - { url = "https://files.pythonhosted.org/packages/3b/cd/154ca20c38269e05eff77c1464e6c1da89f50a6390b565e9d82e06bc11e1/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:37936a16db8fe4ac1f065c2139360608a543a09275cb1a1af612e08cfa065436", size = 423138758, upload-time = "2026-04-08T18:46:58.655Z" }, -] - -[[package]] -name = "nvidia-cuda-cupti" -version = "13.0.85" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/2a/80353b103fc20ce05ef51e928daed4b6015db4aaa9162ed0997090fe2250/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_aarch64.whl", hash = "sha256:796bd679890ee55fb14a94629b698b6db54bcfd833d391d5e94017dd9d7d3151", size = 10310827, upload-time = "2025-09-04T08:26:42.012Z" }, - { url = "https://files.pythonhosted.org/packages/33/6d/737d164b4837a9bbd202f5ae3078975f0525a55730fe871d8ed4e3b952b0/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl", hash = "sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8", size = 10715597, upload-time = "2025-09-04T08:26:51.312Z" }, -] - -[[package]] -name = "nvidia-cuda-nvrtc" -version = "13.0.88" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575", size = 90215200, upload-time = "2025-09-04T08:28:44.204Z" }, - { url = "https://files.pythonhosted.org/packages/b7/dc/6bb80850e0b7edd6588d560758f17e0550893a1feaf436807d64d2da040f/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b", size = 43015449, upload-time = "2025-09-04T08:28:20.239Z" }, -] - -[[package]] -name = "nvidia-cuda-runtime" -version = "13.0.96" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/87/4f/17d7b9b8e285199c58ce28e31b5c5bbaa4d8271af06a89b6405258245de2/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef9bcbe90493a2b9d810e43d249adb3d02e98dd30200d86607d8d02687c43f55", size = 2261060, upload-time = "2025-10-09T08:55:15.78Z" }, - { url = "https://files.pythonhosted.org/packages/2e/24/d1558f3b68b1d26e706813b1d10aa1d785e4698c425af8db8edc3dced472/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548", size = 2243632, upload-time = "2025-10-09T08:55:36.117Z" }, -] - -[[package]] -name = "nvidia-cudnn-cu13" -version = "9.20.0.48" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nvidia-cublas" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, - { url = "https://files.pythonhosted.org/packages/6e/5e/edb9c0ae051602c3ccaffe424256463636d639e27d7f302dde9975ef9e7a/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0c45dd8eeb50b603f07995b1b300c62ffe6a1980482b82b3bcf94a4ca9d49304", size = 366173588, upload-time = "2026-03-09T19:29:34.474Z" }, -] - -[[package]] -name = "nvidia-cufft" -version = "12.0.0.61" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nvidia-nvjitlink" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, - { url = "https://files.pythonhosted.org/packages/a8/2f/7b57e29836ea8714f81e9898409196f47d772d5ddedddf1592eadb8ab743/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3", size = 214085489, upload-time = "2025-09-04T08:31:56.044Z" }, -] - -[[package]] -name = "nvidia-cufile" -version = "1.15.1.6" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/70/4f193de89a48b71714e74602ee14d04e4019ad36a5a9f20c425776e72cd6/nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44", size = 1223672, upload-time = "2025-09-04T08:32:22.779Z" }, - { url = "https://files.pythonhosted.org/packages/ab/73/cc4a14c9813a8a0d509417cf5f4bdaba76e924d58beb9864f5a7baceefbf/nvidia_cufile-1.15.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:bdc0deedc61f548bddf7733bdc216456c2fdb101d020e1ab4b88d232d5e2f6d1", size = 1136992, upload-time = "2025-09-04T08:32:14.119Z" }, -] - -[[package]] -name = "nvidia-curand" -version = "10.4.0.35" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/72/7c2ae24fb6b63a32e6ae5d241cc65263ea18d08802aaae087d9f013335a2/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:133df5a7509c3e292aaa2b477afd0194f06ce4ea24d714d616ff36439cee349a", size = 61962106, upload-time = "2025-08-04T10:21:41.128Z" }, - { url = "https://files.pythonhosted.org/packages/a5/9f/be0a41ca4a4917abf5cb9ae0daff1a6060cc5de950aec0396de9f3b52bc5/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc", size = 59544258, upload-time = "2025-08-04T10:22:03.992Z" }, -] - -[[package]] -name = "nvidia-cusolver" -version = "12.0.4.66" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nvidia-cublas" }, - { name = "nvidia-cusparse" }, - { name = "nvidia-nvjitlink" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, - { url = "https://files.pythonhosted.org/packages/5f/67/cba3777620cdacb99102da4042883709c41c709f4b6323c10781a9c3aa34/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112", size = 200941980, upload-time = "2025-09-04T08:33:22.767Z" }, -] - -[[package]] -name = "nvidia-cusparse" -version = "12.6.3.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nvidia-nvjitlink" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, - { url = "https://files.pythonhosted.org/packages/fa/18/623c77619c31d62efd55302939756966f3ecc8d724a14dab2b75f1508850/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b", size = 145942937, upload-time = "2025-09-04T08:33:58.029Z" }, -] - -[[package]] -name = "nvidia-cusparselt-cu13" -version = "0.8.1" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/46/e1/cdc1797eadf82d3a9a575a19b33fdc871a97edbec42c00b5b5e914f4aff4/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:4dca476c50bf4780d46cd0bfbd82e2bc10a08e4fef7950917ce8d7578d22a23f", size = 221051344, upload-time = "2025-09-05T18:49:51.289Z" }, - { url = "https://files.pythonhosted.org/packages/34/7d/2661f2fb3ac4302f3a246f5fc030213ac60c1fe0bce84f9783dbd831dbb7/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:786ce87568c303fadb5afcc7102d454cd3040d75f6f8626f5db460d1871f4dd0", size = 170148586, upload-time = "2025-09-05T18:50:50.248Z" }, -] - -[[package]] -name = "nvidia-nccl-cu13" -version = "2.29.7" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/72/0d/daf50d44177ee0cbc7ff0a0c91eb5ff676c82be42f9a970bc7597f440c3a/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:674a12383e3c38a1bcccae7d4f3633b37852230b6047883cb2f4c2d1b36d9bf5", size = 206014712, upload-time = "2026-03-03T05:34:20.843Z" }, - { url = "https://files.pythonhosted.org/packages/67/f4/58e4e91b6919367c7aafb8e36fce9aad1a3047e536bf7e2fd560927d3a4c/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:edd81538446786ec3b73972543e53bb43bcaf0bfc8ef76cb679fcc390ffe136d", size = 205976000, upload-time = "2026-03-03T05:36:24.472Z" }, -] - -[[package]] -name = "nvidia-nvjitlink" -version = "13.0.88" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/56/7a/123e033aaff487c77107195fa5a2b8686795ca537935a24efae476c41f05/nvidia_nvjitlink-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:13a74f429e23b921c1109976abefacc69835f2f433ebd323d3946e11d804e47b", size = 40713933, upload-time = "2025-09-04T08:35:43.553Z" }, - { url = "https://files.pythonhosted.org/packages/ab/2c/93c5250e64df4f894f1cbb397c6fd71f79813f9fd79d7cd61de3f97b3c2d/nvidia_nvjitlink-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e931536ccc7d467a98ba1d8b89ff7fa7f1fa3b13f2b0069118cd7f47bff07d0c", size = 38768748, upload-time = "2025-09-04T08:35:20.008Z" }, -] - -[[package]] -name = "nvidia-nvshmem-cu13" -version = "3.4.5" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/0f/05cc9c720236dcd2db9c1ab97fff629e96821be2e63103569da0c9b72f19/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dc2a197f38e5d0376ad52cd1a2a3617d3cdc150fd5966f4aee9bcebb1d68fe9", size = 60215947, upload-time = "2025-09-06T00:32:20.022Z" }, - { url = "https://files.pythonhosted.org/packages/3c/35/a9bf80a609e74e3b000fef598933235c908fcefcef9026042b8e6dfde2a9/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80", size = 60412546, upload-time = "2025-09-06T00:32:41.564Z" }, -] - -[[package]] -name = "nvidia-nvtx" -version = "13.0.85" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/f3/d86c845465a2723ad7e1e5c36dcd75ddb82898b3f53be47ebd429fb2fa5d/nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4", size = 148047, upload-time = "2025-09-04T08:29:01.761Z" }, - { url = "https://files.pythonhosted.org/packages/a8/64/3708a90d1ebe202ffdeb7185f878a3c84d15c2b2c31858da2ce0583e2def/nvidia_nvtx-13.0.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6", size = 148878, upload-time = "2025-09-04T08:28:53.627Z" }, -] - -[[package]] -name = "oauthlib" -version = "3.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, -] - -[[package]] -name = "onnxruntime" -version = "1.24.3" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -dependencies = [ - { name = "flatbuffers", marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "packaging", marker = "python_full_version < '3.11'" }, - { name = "protobuf", marker = "python_full_version < '3.11'" }, - { name = "sympy", marker = "python_full_version < '3.11'" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/15/41/3253db975a90c3ce1d475e2a230773a21cd7998537f0657947df6fb79861/onnxruntime-1.24.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3e6456801c66b095c5cd68e690ca25db970ea5202bd0c5b84a2c3ef7731c5a3c", size = 17332766, upload-time = "2026-03-05T17:18:59.714Z" }, - { url = "https://files.pythonhosted.org/packages/7e/c5/3af6b325f1492d691b23844d88ed26844c1164620860c5efe95c0e22782d/onnxruntime-1.24.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b2ebc54c6d8281dccff78d4b06e47d4cf07535937584ab759448390a70f4978", size = 15130330, upload-time = "2026-03-05T16:34:53.831Z" }, - { url = "https://files.pythonhosted.org/packages/03/4b/f96b46c1866a293ed23ca2cf5e5a63d413ad3a951da60dd877e3c56cbbca/onnxruntime-1.24.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb56575d7794bf0781156955610c9e651c9504c64d42ec880784b6106244882d", size = 17213247, upload-time = "2026-03-05T17:17:59.812Z" }, - { url = "https://files.pythonhosted.org/packages/36/13/27cf4d8df2578747584e8758aeb0b673b60274048510257f1f084b15e80e/onnxruntime-1.24.3-cp311-cp311-win_amd64.whl", hash = "sha256:c958222ef9eff54018332beecd32d5d94a3ab079d8821937b333811bf4da0d39", size = 12595530, upload-time = "2026-03-05T17:18:49.356Z" }, - { url = "https://files.pythonhosted.org/packages/19/8c/6d9f31e6bae72a8079be12ed8ba36c4126a571fad38ded0a1b96f60f6896/onnxruntime-1.24.3-cp311-cp311-win_arm64.whl", hash = "sha256:a8f761857ebaf58a85b9e42422d03207f1d39e6bb8fecfdbf613bac5b9710723", size = 12261715, upload-time = "2026-03-05T17:18:39.699Z" }, - { url = "https://files.pythonhosted.org/packages/d0/7f/dfdc4e52600fde4c02d59bfe98c4b057931c1114b701e175aee311a9bc11/onnxruntime-1.24.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:0d244227dc5e00a9ae15a7ac1eba4c4460d7876dfecafe73fb00db9f1d914d91", size = 17342578, upload-time = "2026-03-05T17:19:02.403Z" }, - { url = "https://files.pythonhosted.org/packages/1c/dc/1f5489f7b21817d4ad352bf7a92a252bd5b438bcbaa7ad20ea50814edc79/onnxruntime-1.24.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a9847b870b6cb462652b547bc98c49e0efb67553410a082fde1918a38707452", size = 15150105, upload-time = "2026-03-05T16:34:56.897Z" }, - { url = "https://files.pythonhosted.org/packages/28/7c/fd253da53594ab8efbefdc85b3638620ab1a6aab6eb7028a513c853559ce/onnxruntime-1.24.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b354afce3333f2859c7e8706d84b6c552beac39233bcd3141ce7ab77b4cabb5d", size = 17237101, upload-time = "2026-03-05T17:18:02.561Z" }, - { url = "https://files.pythonhosted.org/packages/71/5f/eaabc5699eeed6a9188c5c055ac1948ae50138697a0428d562ac970d7db5/onnxruntime-1.24.3-cp312-cp312-win_amd64.whl", hash = "sha256:44ea708c34965439170d811267c51281d3897ecfc4aa0087fa25d4a4c3eb2e4a", size = 12597638, upload-time = "2026-03-05T17:18:52.141Z" }, - { url = "https://files.pythonhosted.org/packages/cc/5c/d8066c320b90610dbeb489a483b132c3b3879b2f93f949fb5d30cfa9b119/onnxruntime-1.24.3-cp312-cp312-win_arm64.whl", hash = "sha256:48d1092b44ca2ba6f9543892e7c422c15a568481403c10440945685faf27a8d8", size = 12270943, upload-time = "2026-03-05T17:18:42.006Z" }, - { url = "https://files.pythonhosted.org/packages/51/8d/487ece554119e2991242d4de55de7019ac6e47ee8dfafa69fcf41d37f8ed/onnxruntime-1.24.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:34a0ea5ff191d8420d9c1332355644148b1bf1a0d10c411af890a63a9f662aa7", size = 17342706, upload-time = "2026-03-05T16:35:10.813Z" }, - { url = "https://files.pythonhosted.org/packages/dd/25/8b444f463c1ac6106b889f6235c84f01eec001eaf689c3eff8c69cf48fae/onnxruntime-1.24.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fd2ec7bb0fabe42f55e8337cfc9b1969d0d14622711aac73d69b4bd5abb5ed7", size = 15149956, upload-time = "2026-03-05T16:34:59.264Z" }, - { url = "https://files.pythonhosted.org/packages/34/fc/c9182a3e1ab46940dd4f30e61071f59eee8804c1f641f37ce6e173633fb6/onnxruntime-1.24.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df8e70e732fe26346faaeec9147fa38bef35d232d2495d27e93dd221a2d473a9", size = 17237370, upload-time = "2026-03-05T17:18:05.258Z" }, - { url = "https://files.pythonhosted.org/packages/05/7e/3b549e1f4538514118bff98a1bcd6481dd9a17067f8c9af77151621c9a5c/onnxruntime-1.24.3-cp313-cp313-win_amd64.whl", hash = "sha256:2d3706719be6ad41d38a2250998b1d87758a20f6ea4546962e21dc79f1f1fd2b", size = 12597939, upload-time = "2026-03-05T17:18:54.772Z" }, - { url = "https://files.pythonhosted.org/packages/80/41/9696a5c4631a0caa75cc8bc4efd30938fd483694aa614898d087c3ee6d29/onnxruntime-1.24.3-cp313-cp313-win_arm64.whl", hash = "sha256:b082f3ba9519f0a1a1e754556bc7e635c7526ef81b98b3f78da4455d25f0437b", size = 12270705, upload-time = "2026-03-05T17:18:44.774Z" }, - { url = "https://files.pythonhosted.org/packages/b7/65/a26c5e59e3b210852ee04248cf8843c81fe7d40d94cf95343b66efe7eec9/onnxruntime-1.24.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72f956634bc2e4bd2e8b006bef111849bd42c42dea37bd0a4c728404fdaf4d34", size = 15161796, upload-time = "2026-03-05T16:35:02.871Z" }, - { url = "https://files.pythonhosted.org/packages/f3/25/2035b4aa2ccb5be6acf139397731ec507c5f09e199ab39d3262b22ffa1ac/onnxruntime-1.24.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78d1f25eed4ab9959db70a626ed50ee24cf497e60774f59f1207ac8556399c4d", size = 17240936, upload-time = "2026-03-05T17:18:09.534Z" }, - { url = "https://files.pythonhosted.org/packages/f9/a4/b3240ea84b92a3efb83d49cc16c04a17ade1ab47a6a95c4866d15bf0ac35/onnxruntime-1.24.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:a6b4bce87d96f78f0a9bf5cefab3303ae95d558c5bfea53d0bf7f9ea207880a8", size = 17344149, upload-time = "2026-03-05T16:35:13.382Z" }, - { url = "https://files.pythonhosted.org/packages/bb/4a/4b56757e51a56265e8c56764d9c36d7b435045e05e3b8a38bedfc5aedba3/onnxruntime-1.24.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d48f36c87b25ab3b2b4c88826c96cf1399a5631e3c2c03cc27d6a1e5d6b18eb4", size = 15151571, upload-time = "2026-03-05T16:35:05.679Z" }, - { url = "https://files.pythonhosted.org/packages/cf/14/c6fb84980cec8f682a523fcac7c2bdd6b311e7f342c61ce48d3a9cb87fc6/onnxruntime-1.24.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e104d33a409bf6e3f30f0e8198ec2aaf8d445b8395490a80f6e6ad56da98e400", size = 17238951, upload-time = "2026-03-05T17:18:12.394Z" }, - { url = "https://files.pythonhosted.org/packages/57/14/447e1400165aca8caf35dabd46540eb943c92f3065927bb4d9bcbc91e221/onnxruntime-1.24.3-cp314-cp314-win_amd64.whl", hash = "sha256:e785d73fbd17421c2513b0bb09eb25d88fa22c8c10c3f5d6060589efa5537c5b", size = 12903820, upload-time = "2026-03-05T17:18:57.123Z" }, - { url = "https://files.pythonhosted.org/packages/1d/ec/6b2fa5702e4bbba7339ca5787a9d056fc564a16079f8833cc6ba4798da1c/onnxruntime-1.24.3-cp314-cp314-win_arm64.whl", hash = "sha256:951e897a275f897a05ffbcaa615d98777882decaeb80c9216c68cdc62f849f53", size = 12594089, upload-time = "2026-03-05T17:18:47.169Z" }, - { url = "https://files.pythonhosted.org/packages/12/dc/cd06cba3ddad92ceb17b914a8e8d49836c79e38936e26bde6e368b62c1fe/onnxruntime-1.24.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d4e70ce578aa214c74c7a7a9226bc8e229814db4a5b2d097333b81279ecde36", size = 15162789, upload-time = "2026-03-05T16:35:08.282Z" }, - { url = "https://files.pythonhosted.org/packages/a6/d6/413e98ab666c6fb9e8be7d1c6eb3bd403b0bea1b8d42db066dab98c7df07/onnxruntime-1.24.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02aaf6ddfa784523b6873b4176a79d508e599efe12ab0ea1a3a6e7314408b7aa", size = 17240738, upload-time = "2026-03-05T17:18:15.203Z" }, -] - -[[package]] -name = "onnxruntime" -version = "1.26.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version == '3.14.*'", - "python_full_version == '3.13.*'", - "python_full_version >= '3.11' and python_full_version < '3.13'", -] -dependencies = [ - { name = "flatbuffers", marker = "python_full_version >= '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "packaging", marker = "python_full_version >= '3.11'" }, - { name = "protobuf", marker = "python_full_version >= '3.11'" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/81/29a9eb470994a75eb7b3ccf32be314d7c66675a00ac7b50294816cc2db27/onnxruntime-1.26.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ee1109ef4ef27cad90e823399e61e03b3c6c7bfe0fb820b4baf3678c15be8b3c", size = 18005108, upload-time = "2026-05-08T19:08:11.728Z" }, - { url = "https://files.pythonhosted.org/packages/66/c7/73efa6c8a4000c38fcc14947d84f234a17e5d66f203b37b7f1ad4a7b46eb/onnxruntime-1.26.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35c7c7b0ac2e02001d28fab6c9fc24e9abc5e6faa35e6e19c63cecf1406ba89f", size = 16043752, upload-time = "2026-05-08T19:07:10.707Z" }, - { url = "https://files.pythonhosted.org/packages/b6/3f/8de630f595daf6ce884d4dd95afd2a60e70ec6572e52bfee3aa2229befab/onnxruntime-1.26.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11a8df4dcfe9ad5ff0bd71a7571dbed019fabc7594676c89fe8b86ea029c246f", size = 18176043, upload-time = "2026-05-08T19:07:33.735Z" }, - { url = "https://files.pythonhosted.org/packages/9c/21/9f041de20787cd85498bd48e0ec4d098bf2a6c486e25b24b8dae1bf492b2/onnxruntime-1.26.0-cp311-cp311-win_amd64.whl", hash = "sha256:e6456718125fd777c673f3b78d4a9ab58d6adea641e9afae85ee6444f0e0e9a9", size = 13023165, upload-time = "2026-05-08T19:08:00.633Z" }, - { url = "https://files.pythonhosted.org/packages/0e/82/3b9fe0ead2557cc3adf74c74c141bd1c7c4c6a9548c610af37df199f4512/onnxruntime-1.26.0-cp311-cp311-win_arm64.whl", hash = "sha256:cd920e45b730e4a87833e2910d8ca375aaca9da6ccc09e24bce463b3356d637f", size = 12789514, upload-time = "2026-05-08T19:07:49.433Z" }, - { url = "https://files.pythonhosted.org/packages/81/b1/d111b1df656761f980d9e298a60039a9cb66036b1d039e777537743d0ac3/onnxruntime-1.26.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:05b028781b322ad74b57ce5b50aa5280bb1fe96ceec334628ade681e0b24c1ac", size = 18016624, upload-time = "2026-05-12T00:41:01.735Z" }, - { url = "https://files.pythonhosted.org/packages/f6/a0/3f9d896a0385a36bd04345d6d0b802821a5782adde562e7e135f6bb71c73/onnxruntime-1.26.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91f2bb870a4b9224eba0a6728c1fa7a9e552b8e59e1083c51fbbc3d013f2b5c0", size = 16052692, upload-time = "2026-05-08T19:07:13.829Z" }, - { url = "https://files.pythonhosted.org/packages/7c/43/2a4e04f8dbeffad19bbcced4bcd4289bf478921518437404d6b92bdf213b/onnxruntime-1.26.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b6dd70599005bd1bf29779f04a91978b92b5e719c11a20068a8f8e535f725b6", size = 18185439, upload-time = "2026-05-08T19:07:36.299Z" }, - { url = "https://files.pythonhosted.org/packages/44/fc/026d0a7162b9c2153dac292baea9e027c42304dc1d9dc6f8ff5b4cfbaedd/onnxruntime-1.26.0-cp312-cp312-win_amd64.whl", hash = "sha256:a26374dc7fbcaae593601086b242120e13f2310558df0991da6dd8b8fac00414", size = 13026427, upload-time = "2026-05-08T19:08:03.503Z" }, - { url = "https://files.pythonhosted.org/packages/3e/27/1dcf88e45e4c69db5f7b106f2dacc3801ba98994e082ca03e1dfdf7bfe57/onnxruntime-1.26.0-cp312-cp312-win_arm64.whl", hash = "sha256:54a8053410fd31fd66469bd754fcfe8a4df9f7eb44756b4b5479bf50c842d948", size = 12796647, upload-time = "2026-05-08T19:07:52.108Z" }, - { url = "https://files.pythonhosted.org/packages/cf/a2/c801242685e0ce48a4ca51dfafbb588765e0446397e123be53ba5598f3f5/onnxruntime-1.26.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ccce19c5f771b8268902f77d9fed9e88f9499465d6780808faa6611a789d33f0", size = 18016563, upload-time = "2026-05-08T19:07:28.081Z" }, - { url = "https://files.pythonhosted.org/packages/e2/64/0492c0b1db04e29b2630c87cfa36f9d6872b1ca8614b90c5cad58fac7d76/onnxruntime-1.26.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdbed8cf3b672b66acb032f33a253bc27f42bce6ece48ae3fab4fa483a5e96e0", size = 16052634, upload-time = "2026-05-08T19:07:16.885Z" }, - { url = "https://files.pythonhosted.org/packages/3d/26/4d09ddc755a84fc8d5e192991626b0e0680e8f6c5d58f4f1d05c42bc48cf/onnxruntime-1.26.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c07af6fc6d5557835f2b6ee7a96d8b3235d0c57a8e230efdedaee106a8a3cbc6", size = 18185632, upload-time = "2026-05-08T19:07:38.756Z" }, - { url = "https://files.pythonhosted.org/packages/77/89/3e52249aa08fa301e217ecba07b5246a8338fa2b401e109326e3fc5be0f9/onnxruntime-1.26.0-cp313-cp313-win_amd64.whl", hash = "sha256:61bec80655efa460591c2bc655392d57d2650ce85533a6b9b3b7a790d7ea7916", size = 13026751, upload-time = "2026-05-08T19:08:06.2Z" }, - { url = "https://files.pythonhosted.org/packages/06/b3/c1c8782b14af6797c303de132d6eef26a9fb80dfacd3750ce57911d11c6b/onnxruntime-1.26.0-cp313-cp313-win_arm64.whl", hash = "sha256:a6677545ff451e3539a02746d2f207d8c5baa4a0a818886bb9d6a6eb9511ee89", size = 12796807, upload-time = "2026-05-08T19:07:54.879Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f5/47b0676408abec652c14b84d7173e389837832d850c24f87184277313e8d/onnxruntime-1.26.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e016edc15d3c19f36807e1c6b10be5b27807688c32720f91b5ae480a95215d0", size = 16057265, upload-time = "2026-05-08T19:07:19.603Z" }, - { url = "https://files.pythonhosted.org/packages/3b/45/33ab6deeef010ca844c877dd618cebc079590bbe52d2a3678e7223b1b908/onnxruntime-1.26.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f5fc48a91a046a6a5c9b147f83fb41d65d24d24923373b222cdd248f0f4f4aac", size = 18197590, upload-time = "2026-05-08T19:07:41.422Z" }, - { url = "https://files.pythonhosted.org/packages/40/89/17546c1c20f6bfc3ae41c22152378a26edfea918af3129e2139dcd7c99f3/onnxruntime-1.26.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:33a791f31432a3af1a96db5e54818b37aba5e5eefc2e6af5794c10a9118a9993", size = 18019724, upload-time = "2026-05-08T19:07:30.723Z" }, - { url = "https://files.pythonhosted.org/packages/bb/24/89457a35f6af29538a76647f2c18c3a28277e6c19234c847e7b4b7c19860/onnxruntime-1.26.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e90c00732c4553618103149d93f688e8c3063017938f8983e21a71d9f3b6d22e", size = 16054821, upload-time = "2026-05-08T19:07:22.348Z" }, - { url = "https://files.pythonhosted.org/packages/12/f9/15b2e1815cf570d238e0135529f80d2dce64e8e8818a1489cae83823c5c6/onnxruntime-1.26.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01498e80ba8988428d08c2d51b1338f89e3de2a93e6ffe555f79c68f26a5c06b", size = 18185815, upload-time = "2026-05-08T19:07:44.179Z" }, - { url = "https://files.pythonhosted.org/packages/d7/65/2e11055faf015e4b07f45b513fa49b391baf2e19d92d77d73ebee13c1004/onnxruntime-1.26.0-cp314-cp314-win_amd64.whl", hash = "sha256:7ead61450d8405167c87dd3a31d8da1d576b490a57dab1aa8b82a7da6825f5aa", size = 13349887, upload-time = "2026-05-08T19:08:08.671Z" }, - { url = "https://files.pythonhosted.org/packages/19/e4/0f9d1a5718b1781c610c1e354765a3820597081754277a6a9a2b50705702/onnxruntime-1.26.0-cp314-cp314-win_arm64.whl", hash = "sha256:31d71a53490e46910877d0902b5ad99c69a5955e5c7ea6c82863519410e1ba7c", size = 13140121, upload-time = "2026-05-08T19:07:57.804Z" }, - { url = "https://files.pythonhosted.org/packages/1c/42/3b8e635f067d06d9f45bede470b8d539d101a4166c272213158dfd08b6ce/onnxruntime-1.26.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7b6d258fb78fdfcf049795bcfaa74dcb90ae7baa277afd21e6fd28b83f2c496", size = 16057240, upload-time = "2026-05-08T19:07:25.163Z" }, - { url = "https://files.pythonhosted.org/packages/93/99/f2be40a31b908d96b861ae0ce98582fa376c18a7f816b9d5eb4cd6aa0a4c/onnxruntime-1.26.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4eefd386a45202aefb7a5132b94f32df9d506c9edcc7faf2fc60d65183f4b183", size = 18197382, upload-time = "2026-05-08T19:07:46.965Z" }, -] - -[[package]] -name = "openai" -version = "2.41.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "distro" }, - { name = "httpx" }, - { name = "jiter" }, - { name = "pydantic" }, - { name = "sniffio" }, - { name = "tqdm" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3c/a6/5815fe2e2aca74b36c650d1bd43b69827cee568073d0d2d9b6fc5aaac80c/openai-2.41.0.tar.gz", hash = "sha256:db5c362acd6604b84f076abbefa66826ea4b46ecba2954ed866e6a149a1352c0", size = 783525, upload-time = "2026-06-03T22:39:40.719Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/be/51/d82bb424e8aa372190c5233253a2ceb399a778747d18b42cff487411e663/openai-2.41.0-py3-none-any.whl", hash = "sha256:20cc7952e8501c7e5773dd2ef7be437bae9cb549044902e1041a83a54516e375", size = 1353378, upload-time = "2026-06-03T22:39:38.964Z" }, -] - -[[package]] -name = "openpyxl" -version = "3.1.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "et-xmlfile" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, -] - [[package]] name = "opentelemetry-api" version = "1.34.1" @@ -3586,54 +2222,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a5/3a/2ba85557e8dc024c0842ad22c570418dc02c36cbd1ab4b832a93edf071b8/opentelemetry_api-1.34.1-py3-none-any.whl", hash = "sha256:b7df4cb0830d5a6c29ad0c0691dbae874d8daefa934b8b1d642de48323d32a8c", size = 65767, upload-time = "2025-06-10T08:54:56.717Z" }, ] -[[package]] -name = "opentelemetry-exporter-otlp-proto-common" -version = "1.34.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-proto" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/86/f0/ff235936ee40db93360233b62da932d4fd9e8d103cd090c6bcb9afaf5f01/opentelemetry_exporter_otlp_proto_common-1.34.1.tar.gz", hash = "sha256:b59a20a927facd5eac06edaf87a07e49f9e4a13db487b7d8a52b37cb87710f8b", size = 20817, upload-time = "2025-06-10T08:55:22.55Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/72/e8/8b292a11cc8d8d87ec0c4089ae21b6a58af49ca2e51fa916435bc922fdc7/opentelemetry_exporter_otlp_proto_common-1.34.1-py3-none-any.whl", hash = "sha256:8e2019284bf24d3deebbb6c59c71e6eef3307cd88eff8c633e061abba33f7e87", size = 18834, upload-time = "2025-06-10T08:55:00.806Z" }, -] - -[[package]] -name = "opentelemetry-exporter-otlp-proto-grpc" -version = "1.34.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "googleapis-common-protos" }, - { name = "grpcio" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-otlp-proto-common" }, - { name = "opentelemetry-proto" }, - { name = "opentelemetry-sdk" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/41/f7/bb63837a3edb9ca857aaf5760796874e7cecddc88a2571b0992865a48fb6/opentelemetry_exporter_otlp_proto_grpc-1.34.1.tar.gz", hash = "sha256:7c841b90caa3aafcfc4fee58487a6c71743c34c6dc1787089d8b0578bbd794dd", size = 22566, upload-time = "2025-06-10T08:55:23.214Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/42/0a4dd47e7ef54edf670c81fc06a83d68ea42727b82126a1df9dd0477695d/opentelemetry_exporter_otlp_proto_grpc-1.34.1-py3-none-any.whl", hash = "sha256:04bb8b732b02295be79f8a86a4ad28fae3d4ddb07307a98c7aa6f331de18cca6", size = 18615, upload-time = "2025-06-10T08:55:02.214Z" }, -] - -[[package]] -name = "opentelemetry-exporter-otlp-proto-http" -version = "1.34.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "googleapis-common-protos" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-otlp-proto-common" }, - { name = "opentelemetry-proto" }, - { name = "opentelemetry-sdk" }, - { name = "requests" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/8f/954bc725961cbe425a749d55c0ba1df46832a5999eae764d1a7349ac1c29/opentelemetry_exporter_otlp_proto_http-1.34.1.tar.gz", hash = "sha256:aaac36fdce46a8191e604dcf632e1f9380c7d5b356b27b3e0edb5610d9be28ad", size = 15351, upload-time = "2025-06-10T08:55:24.657Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/54/b05251c04e30c1ac70cf4a7c5653c085dfcf2c8b98af71661d6a252adc39/opentelemetry_exporter_otlp_proto_http-1.34.1-py3-none-any.whl", hash = "sha256:5251f00ca85872ce50d871f6d3cc89fe203b94c3c14c964bbdc3883366c705d8", size = 17744, upload-time = "2025-06-10T08:55:03.802Z" }, -] - [[package]] name = "opentelemetry-instrumentation" version = "0.55b1" @@ -3663,18 +2251,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/05/c9/183ad41a7ba0374030b3eab335ec6f3eff6acca057aba2b393183e18639e/opentelemetry_instrumentation_threading-0.55b1-py3-none-any.whl", hash = "sha256:f865542b32b219c8fd01deb03b8c3c9ba2eb3f0501ae303338403fd2242962c7", size = 9313, upload-time = "2025-06-10T08:58:02.884Z" }, ] -[[package]] -name = "opentelemetry-proto" -version = "1.34.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/b3/c3158dd012463bb7c0eb7304a85a6f63baeeb5b4c93a53845cf89f848c7e/opentelemetry_proto-1.34.1.tar.gz", hash = "sha256:16286214e405c211fc774187f3e4bbb1351290b8dfb88e8948af209ce85b719e", size = 34344, upload-time = "2025-06-10T08:55:32.25Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/28/ab/4591bfa54e946350ce8b3f28e5c658fe9785e7cd11e9c11b1671a867822b/opentelemetry_proto-1.34.1-py3-none-any.whl", hash = "sha256:eb4bb5ac27f2562df2d6857fc557b3a481b5e298bc04f94cc68041f00cebcbd2", size = 55692, upload-time = "2025-06-10T08:55:14.904Z" }, -] - [[package]] name = "opentelemetry-sdk" version = "1.34.1" @@ -3783,15 +2359,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, ] -[[package]] -name = "overrides" -version = "7.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/36/86/b585f53236dec60aba864e050778b25045f857e17f6e5ea0ae95fe80edd2/overrides-7.7.0.tar.gz", hash = "sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a", size = 22812, upload-time = "2024-01-27T21:01:33.423Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/ab/fc8290c6a4c722e5514d80f62b2dc4c4df1a68a41d1364e625c35990fcf3/overrides-7.7.0-py3-none-any.whl", hash = "sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49", size = 17832, upload-time = "2024-01-27T21:01:31.393Z" }, -] - [[package]] name = "packaging" version = "26.2" @@ -3802,183 +2369,21 @@ wheels = [ ] [[package]] -name = "pathspec" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, -] - -[[package]] -name = "pdfminer-six" -version = "20251230" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "charset-normalizer" }, - { name = "cryptography" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/46/9a/d79d8fa6d47a0338846bb558b39b9963b8eb2dfedec61867c138c1b17eeb/pdfminer_six-20251230.tar.gz", hash = "sha256:e8f68a14c57e00c2d7276d26519ea64be1b48f91db1cdc776faa80528ca06c1e", size = 8511285, upload-time = "2025-12-30T15:49:13.104Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/65/d7/b288ea32deb752a09aab73c75e1e7572ab2a2b56c3124a5d1eb24c62ceb3/pdfminer_six-20251230-py3-none-any.whl", hash = "sha256:9ff2e3466a7dfc6de6fd779478850b6b7c2d9e9405aa2a5869376a822771f485", size = 6591909, upload-time = "2025-12-30T15:49:10.76Z" }, -] - -[[package]] -name = "pdfplumber" -version = "0.11.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pdfminer-six" }, - { name = "pillow" }, - { name = "pypdfium2" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/38/37/9ca3519e92a8434eb93be570b131476cc0a4e840bb39c62ddb7813a39d53/pdfplumber-0.11.9.tar.gz", hash = "sha256:481224b678b2bbdbf376e2c39bf914144eef7c3d301b4a28eebf0f7f6109d6dc", size = 102768, upload-time = "2026-01-05T08:10:29.072Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/c8/cdbc975f5b634e249cfa6597e37c50f3078412474f21c015e508bfbfe3c3/pdfplumber-0.11.9-py3-none-any.whl", hash = "sha256:33ec5580959ba524e9100138746e090879504c42955df1b8a997604dd326c443", size = 60045, upload-time = "2026-01-05T08:10:27.512Z" }, -] - -[[package]] -name = "pillow" -version = "12.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/aa/d0b28e1c811cd4d5f5c2bfe2e022292bd255ae5744a3b9ac7d6c8f72dd75/pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f", size = 5354355, upload-time = "2026-04-01T14:42:15.402Z" }, - { url = "https://files.pythonhosted.org/packages/27/8e/1d5b39b8ae2bd7650d0c7b6abb9602d16043ead9ebbfef4bc4047454da2a/pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97", size = 4695871, upload-time = "2026-04-01T14:42:18.234Z" }, - { url = "https://files.pythonhosted.org/packages/f0/c5/dcb7a6ca6b7d3be41a76958e90018d56c8462166b3ef223150360850c8da/pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff", size = 6269734, upload-time = "2026-04-01T14:42:20.608Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f1/aa1bb13b2f4eba914e9637893c73f2af8e48d7d4023b9d3750d4c5eb2d0c/pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec", size = 8076080, upload-time = "2026-04-01T14:42:23.095Z" }, - { url = "https://files.pythonhosted.org/packages/a1/2a/8c79d6a53169937784604a8ae8d77e45888c41537f7f6f65ed1f407fe66d/pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136", size = 6382236, upload-time = "2026-04-01T14:42:25.82Z" }, - { url = "https://files.pythonhosted.org/packages/b5/42/bbcb6051030e1e421d103ce7a8ecadf837aa2f39b8f82ef1a8d37c3d4ebc/pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c", size = 7070220, upload-time = "2026-04-01T14:42:28.68Z" }, - { url = "https://files.pythonhosted.org/packages/3f/e1/c2a7d6dd8cfa6b231227da096fd2d58754bab3603b9d73bf609d3c18b64f/pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3", size = 6493124, upload-time = "2026-04-01T14:42:31.579Z" }, - { url = "https://files.pythonhosted.org/packages/5f/41/7c8617da5d32e1d2f026e509484fdb6f3ad7efaef1749a0c1928adbb099e/pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa", size = 7194324, upload-time = "2026-04-01T14:42:34.615Z" }, - { url = "https://files.pythonhosted.org/packages/2d/de/a777627e19fd6d62f84070ee1521adde5eeda4855b5cf60fe0b149118bca/pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032", size = 6376363, upload-time = "2026-04-01T14:42:37.19Z" }, - { url = "https://files.pythonhosted.org/packages/e7/34/fc4cb5204896465842767b96d250c08410f01f2f28afc43b257de842eed5/pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5", size = 7083523, upload-time = "2026-04-01T14:42:39.62Z" }, - { url = "https://files.pythonhosted.org/packages/2d/a0/32852d36bc7709f14dc3f64f929a275e958ad8c19a6deba9610d458e28b3/pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024", size = 2463318, upload-time = "2026-04-01T14:42:42.063Z" }, - { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, - { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, - { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, - { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, - { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, - { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, - { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, - { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, - { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, - { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, - { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, - { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, - { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, - { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, - { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, - { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, - { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, - { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, - { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, - { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, - { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, - { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, - { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, - { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, - { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, - { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, - { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, - { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, - { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, - { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, - { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, - { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, - { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, - { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, - { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, - { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, - { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, - { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, - { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, - { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, - { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, - { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, - { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, - { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, - { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, - { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, - { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, - { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, - { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, - { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, - { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, - { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, - { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, - { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, - { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, - { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, - { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, - { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, - { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, - { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, - { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, - { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, - { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, - { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, - { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, -] - -[[package]] -name = "platformdirs" -version = "4.10.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, -] - -[[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 = "portalocker" -version = "2.7.0" +name = "pathspec" +version = "1.1.1" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pywin32", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1f/f8/969e6f280201b40b31bcb62843c619f343dcc351dff83a5891530c9dd60e/portalocker-2.7.0.tar.gz", hash = "sha256:032e81d534a88ec1736d03f780ba073f047a06c478b06e2937486f334e955c51", size = 20183, upload-time = "2023-01-18T23:36:14.436Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/df/d4f711d168524f5aebd7fb30969eaa31e3048cf8979688cde3b08f6e5eb8/portalocker-2.7.0-py2.py3-none-any.whl", hash = "sha256:a07c5b4f3985c3cf4798369631fb7011adb498e2a46d8440efc75a8f29a0f983", size = 15502, upload-time = "2023-01-18T23:36:12.849Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] [[package]] -name = "posthog" -version = "5.4.0" +name = "pluggy" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "backoff" }, - { name = "distro" }, - { name = "python-dateutil" }, - { name = "requests" }, - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/48/20/60ae67bb9d82f00427946218d49e2e7e80fb41c15dc5019482289ec9ce8d/posthog-5.4.0.tar.gz", hash = "sha256:701669261b8d07cdde0276e5bc096b87f9e200e3b9589c5ebff14df658c5893c", size = 88076, upload-time = "2025-06-20T23:19:23.485Z" } +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/4f/98/e480cab9a08d1c09b1c59a93dade92c1bb7544826684ff2acbfd10fcfbd4/posthog-5.4.0-py3-none-any.whl", hash = "sha256:284dfa302f64353484420b52d4ad81ff5c2c2d1d607c4e2db602ac72761831bd", size = 105364, upload-time = "2025-06-20T23:19:22.001Z" }, + { 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]] @@ -4135,63 +2540,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/cb/e3065b447186cb70aa65acc70c86baf482d82bf75625bf5a2c4f6919c6a3/protobuf-5.29.6-py3-none-any.whl", hash = "sha256:6b9edb641441b2da9fa8f428760fc136a49cf97a52076010cf22a2ff73438a86", size = 173126, upload-time = "2026-02-04T22:54:39.462Z" }, ] -[[package]] -name = "pyarrow" -version = "24.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83", size = 1180261, upload-time = "2026-04-21T10:51:25.837Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/bf/a34fee1d624152124fa8355c42f34195ad5fe5233ce5bb87946432047d52/pyarrow-24.0.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:7c2b98645d576a0b9616892ead22b64a83a5f043c5e2ca15ebcefcb5b70c80cb", size = 35076681, upload-time = "2026-04-21T08:51:46.845Z" }, - { url = "https://files.pythonhosted.org/packages/1d/41/64180033d7027afce12dc96d0fe1f504c6fa112190582b458acea2399530/pyarrow-24.0.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:644a246325b8c69c595ad1dd4b463eba4b0cdb731370e4a86137d433208d6147", size = 36684260, upload-time = "2026-04-21T08:51:53.642Z" }, - { url = "https://files.pythonhosted.org/packages/57/02/9b9320e673dd8a99411fac78690f3df92f6dd6f59754c750110bca66d64e/pyarrow-24.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:3a577bd840ca83f646f0a625dbc571dba7044c43c2d1503afc378b570954345c", size = 45698566, upload-time = "2026-04-21T10:46:02.133Z" }, - { url = "https://files.pythonhosted.org/packages/67/33/f75e91b9a64c3f33c787e263c93b871ad91b8a4a68c1d5cebddd9840e835/pyarrow-24.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:e3268e43984d0b1a185c89b4cfff282a7ead12fc93f56cfd7088bdbcbe727041", size = 48835562, upload-time = "2026-04-21T10:46:10.278Z" }, - { url = "https://files.pythonhosted.org/packages/a5/63/097510448e47e4091faa41c43ba92f97cecaab8f4535b56a3d149578f634/pyarrow-24.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2392d954fcb920f42d230284b677605e4e2fbb11f2821e823e642abd67fbb491", size = 49394997, upload-time = "2026-04-21T10:46:18.08Z" }, - { url = "https://files.pythonhosted.org/packages/60/6b/c047d6222ab279024a062742d1807e2fbaf27bba88a98637299ff47b9236/pyarrow-24.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bec9373df11544592b0ba7ec2af0e35059e5f0e7647c6183a854dedd193298f1", size = 51911424, upload-time = "2026-04-21T10:46:25.347Z" }, - { url = "https://files.pythonhosted.org/packages/3a/ba/464cc70761c2a525d97ebd84e21c31ebd47f3ef4bdcee117009f51c46f24/pyarrow-24.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:c42ab9439498270139cc63e18847a02afe5c8b3ed9c931266533cfe378bd3591", size = 27251730, upload-time = "2026-04-21T10:46:30.913Z" }, - { url = "https://files.pythonhosted.org/packages/62/c9/a47ab7ece0d86cbe6678418a0fbd1ac4bb493b9184a3891dfa0e7f287ae0/pyarrow-24.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:b0e131f880cda8d04e076cee175a46fc0e8bc8b65c99c6c09dff6669335fde74", size = 35068898, upload-time = "2026-04-21T10:46:36.599Z" }, - { url = "https://files.pythonhosted.org/packages/d1/bc/8db86617a9a58008acf8913d6fed68ea2a46acb6de928db28d724c891a68/pyarrow-24.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:1b2fe7f9a5566401a0ef2571f197eb92358925c1f0c8dba305d6e43ea0871bb3", size = 36679915, upload-time = "2026-04-21T10:46:42.602Z" }, - { url = "https://files.pythonhosted.org/packages/eb/8e/fb178720400ef69db251eb4a9c3ccf4af269bc1feb5055529b8fc87170d1/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:0b3537c00fb8d384f15ac1e79b6eb6db04a16514c8c1d22e59a9b95c8ba42868", size = 45697931, upload-time = "2026-04-21T10:46:48.403Z" }, - { url = "https://files.pythonhosted.org/packages/f3/27/99c42abe8e21b44f4917f62631f3aa31404882a2c41d8a4cd5c110e13d52/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:14e31a3c9e35f1ab6356c6378f6f72830e6d2d5f1791df3774a7b097d18a6a1e", size = 48837449, upload-time = "2026-04-21T10:46:55.329Z" }, - { url = "https://files.pythonhosted.org/packages/36/b6/333749e2666e9032891125bf9c691146e92901bece62030ac1430e2e7c88/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b7d9a514e73bc42711e6a35aaccf3587c520024fe0a25d830a1a8a27c15f4f57", size = 49395949, upload-time = "2026-04-21T10:47:01.869Z" }, - { url = "https://files.pythonhosted.org/packages/17/25/c5201706a2dd374e8ba6ee3fd7a8c89fb7ffc16eed5217a91fd2bd7f7626/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b196eb3f931862af3fa84c2a253514d859c08e0d8fe020e07be12e75a5a9780c", size = 51912986, upload-time = "2026-04-21T10:47:09.872Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d2/4d1bbba65320b21a49678d6fbdc6ff7c649251359fdcfc03568c4136231d/pyarrow-24.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:35405aecb474e683fb36af650618fd5340ee5471fc65a21b36076a18bbc6c981", size = 27255371, upload-time = "2026-04-21T10:47:15.943Z" }, - { url = "https://files.pythonhosted.org/packages/b4/a9/9686d9f07837f91f775e8932659192e02c74f9d8920524b480b85212cc68/pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810", size = 34981559, upload-time = "2026-04-21T10:47:22.17Z" }, - { url = "https://files.pythonhosted.org/packages/80/b6/0ddf0e9b6ead3474ab087ae598c76b031fc45532bf6a63f3a553440fb258/pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a", size = 36663654, upload-time = "2026-04-21T10:47:28.315Z" }, - { url = "https://files.pythonhosted.org/packages/7c/3b/926382efe8ce27ba729071d3566ade6dfb86bdf112f366000196b2f5780a/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66", size = 45679394, upload-time = "2026-04-21T10:47:34.821Z" }, - { url = "https://files.pythonhosted.org/packages/b3/7a/829f7d9dfd37c207206081d6dad474d81dde29952401f07f2ba507814818/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb", size = 48863122, upload-time = "2026-04-21T10:47:42.056Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e8/f88ce625fe8babaae64e8db2d417c7653adb3019b08aae85c5ed787dc816/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e", size = 49376032, upload-time = "2026-04-21T10:47:48.967Z" }, - { url = "https://files.pythonhosted.org/packages/36/7a/82c363caa145fff88fb475da50d3bf52bb024f61917be5424c3392eaf878/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6", size = 51929490, upload-time = "2026-04-21T10:47:55.981Z" }, - { url = "https://files.pythonhosted.org/packages/66/1c/e3e72c8014ad2743ca64a701652c733cc5cbcee15c0463a32a8c55518d9e/pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826", size = 27355660, upload-time = "2026-04-21T10:48:01.718Z" }, - { url = "https://files.pythonhosted.org/packages/6f/d3/a1abf004482026ddc17f4503db227787fa3cfe41ec5091ff20e4fea55e57/pyarrow-24.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba", size = 34976759, upload-time = "2026-04-21T10:48:07.258Z" }, - { url = "https://files.pythonhosted.org/packages/4f/4a/34f0a36d28a2dd32225301b79daad44e243dc1a2bb77d43b60749be255c4/pyarrow-24.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68", size = 36658471, upload-time = "2026-04-21T10:48:13.347Z" }, - { url = "https://files.pythonhosted.org/packages/1f/78/543b94712ae8bb1a6023bcc1acf1a740fbff8286747c289cd9468fced2a5/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2", size = 45675981, upload-time = "2026-04-21T10:48:20.201Z" }, - { url = "https://files.pythonhosted.org/packages/84/9f/8fb7c222b100d314137fa40ec050de56cd8c6d957d1cfff685ce72f15b17/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0", size = 48859172, upload-time = "2026-04-21T10:48:27.541Z" }, - { url = "https://files.pythonhosted.org/packages/a7/d3/1ea72538e6c8b3b475ed78d1049a2c518e655761ea50fe1171fc855fcab7/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495", size = 49385733, upload-time = "2026-04-21T10:48:34.7Z" }, - { url = "https://files.pythonhosted.org/packages/c3/be/c3d8b06a1ba35f2260f8e1f771abbee7d5e345c0937aab90675706b1690a/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f", size = 51934335, upload-time = "2026-04-21T10:48:42.099Z" }, - { url = "https://files.pythonhosted.org/packages/9c/62/89e07a1e7329d2cde3e3c6994ba0839a24977a2beda8be6005ea3d860b99/pyarrow-24.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91", size = 27271748, upload-time = "2026-04-21T10:49:42.532Z" }, - { url = "https://files.pythonhosted.org/packages/17/1a/cff3a59f80b5b1658549d46611b67163f65e0664431c076ad728bf9d5af4/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275", size = 35238554, upload-time = "2026-04-21T10:48:48.526Z" }, - { url = "https://files.pythonhosted.org/packages/a8/99/cce0f42a327bfef2c420fb6078a3eb834826e5d6697bf3009fe11d2ad051/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b", size = 36782301, upload-time = "2026-04-21T10:48:55.181Z" }, - { url = "https://files.pythonhosted.org/packages/2a/66/8e560d5ff6793ca29aca213c53eec0dd482dd46cb93b2819e5aab52e4252/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42", size = 45721929, upload-time = "2026-04-21T10:49:03.676Z" }, - { url = "https://files.pythonhosted.org/packages/27/0c/a26e25505d030716e078d9f16eb74973cbf0b33b672884e9f9da1c83b871/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b", size = 48825365, upload-time = "2026-04-21T10:49:11.714Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/771f9ecb0c65e73fe9dccdd1717901b9594f08c4515d000c7c62df573811/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37", size = 49451819, upload-time = "2026-04-21T10:49:21.474Z" }, - { url = "https://files.pythonhosted.org/packages/48/da/61ae89a88732f5a785646f3ec6125dbb640fa98a540eb2b9889caa561403/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca", size = 51909252, upload-time = "2026-04-21T10:49:31.164Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1a/8dd5cafab7b66573fa91c03d06d213356ad4edd71813aa75e08ce2b3a844/pyarrow-24.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d", size = 27388127, upload-time = "2026-04-21T10:49:37.334Z" }, - { url = "https://files.pythonhosted.org/packages/ad/80/d022a34ff05d2cbedd8ccf841fc1f532ecfa9eb5ed1711b56d0e0ea71fc9/pyarrow-24.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838", size = 35007997, upload-time = "2026-04-21T10:49:48.796Z" }, - { url = "https://files.pythonhosted.org/packages/1a/ff/f01485fda6f4e5d441afb8dd5e7681e4db18826c1e271852f5d3957d6a80/pyarrow-24.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b", size = 36678720, upload-time = "2026-04-21T10:49:55.858Z" }, - { url = "https://files.pythonhosted.org/packages/9e/c2/2d2d5fea814237923f71b36495211f20b43a1576f9a4d6da7e751a64ec6f/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795", size = 45741852, upload-time = "2026-04-21T10:50:04.624Z" }, - { url = "https://files.pythonhosted.org/packages/8e/3a/28ba9c1c1ebdbb5f1b94dfebb46f207e52e6a554b7fe4132540fde29a3a0/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26", size = 48889852, upload-time = "2026-04-21T10:50:12.293Z" }, - { url = "https://files.pythonhosted.org/packages/df/51/4a389acfd31dca009f8fb82d7f510bb4130f2b3a8e18cf00194d0687d8ac/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde", size = 49445207, upload-time = "2026-04-21T10:50:20.677Z" }, - { url = "https://files.pythonhosted.org/packages/19/4b/0bab2b23d2ae901b1b9a03c0efd4b2d070256f8ce3fc43f6e58c167b2081/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76", size = 51954117, upload-time = "2026-04-21T10:50:29.14Z" }, - { url = "https://files.pythonhosted.org/packages/29/88/f4e9145da0417b3d2c12035a8492b35ff4a3dbc653e614fcfb51d9dedb38/pyarrow-24.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e", size = 28001155, upload-time = "2026-04-21T10:51:22.337Z" }, - { url = "https://files.pythonhosted.org/packages/79/4f/46a49a63f43526da895b1a45bbb51d5baf8e4d77159f8528fc3e5490007f/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05", size = 35250387, upload-time = "2026-04-21T10:50:35.552Z" }, - { url = "https://files.pythonhosted.org/packages/a0/da/d5e0cd5ef00796922404806d5f00325cdadc3441ce2c13fe7115f2df9a64/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a", size = 36797102, upload-time = "2026-04-21T10:50:42.417Z" }, - { url = "https://files.pythonhosted.org/packages/34/c7/5904145b0a593a05236c882933d439b5720f0a145381179063722fbfc123/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072", size = 45745118, upload-time = "2026-04-21T10:50:49.324Z" }, - { url = "https://files.pythonhosted.org/packages/13/d3/cca42fe166d1c6e4d5b80e530b7949104d10e17508a90ae202dac205ce2a/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931", size = 48844765, upload-time = "2026-04-21T10:50:55.579Z" }, - { url = "https://files.pythonhosted.org/packages/b0/49/942c3b79878ba928324d1e17c274ed84581db8c0a749b24bcf4cbdf15bd3/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699", size = 49471890, upload-time = "2026-04-21T10:51:02.439Z" }, - { url = "https://files.pythonhosted.org/packages/76/97/ff71431000a75d84135a1ace5ca4ba11726a231a8007bbb320a4c54075d5/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136", size = 51932250, upload-time = "2026-04-21T10:51:10.576Z" }, - { url = "https://files.pythonhosted.org/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282, upload-time = "2026-04-21T10:51:16.815Z" }, -] - [[package]] name = "pyasn1" version = "0.6.3" @@ -4213,179 +2561,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, ] -[[package]] -name = "pybase64" -version = "1.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/b8/4ed5c7ad5ec15b08d35cc79ace6145d5c1ae426e46435f4987379439dfea/pybase64-1.4.3.tar.gz", hash = "sha256:c2ed274c9e0ba9c8f9c4083cfe265e66dd679126cd9c2027965d807352f3f053", size = 137272, upload-time = "2025-12-06T13:27:04.013Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/39/47/16d7af6fae7803f4c691856bc0d8d433ccf30e106432e2ef7707ee19a38a/pybase64-1.4.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f63aa7f29139b8a05ce5f97cdb7fad63d29071e5bdc8a638a343311fe996112a", size = 38241, upload-time = "2025-12-06T13:22:27.396Z" }, - { url = "https://files.pythonhosted.org/packages/4d/3e/268beb8d2240ab55396af4d1b45d2494935982212549b92a5f5b57079bd3/pybase64-1.4.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f5943ec1ae87a8b4fe310905bb57205ea4330c75e2c628433a7d9dd52295b588", size = 31672, upload-time = "2025-12-06T13:22:28.854Z" }, - { url = "https://files.pythonhosted.org/packages/80/14/4365fa33222edcc46b6db4973f9e22bda82adfb6ab2a01afff591f1e41c8/pybase64-1.4.3-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5f2b8aef86f35cd5894c13681faf433a1fffc5b2e76544dcb5416a514a1a8347", size = 65978, upload-time = "2025-12-06T13:22:30.191Z" }, - { url = "https://files.pythonhosted.org/packages/1c/22/e89739d8bc9b96c68ead44b4eec42fe555683d9997e4ba65216d384920fc/pybase64-1.4.3-cp310-cp310-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6ec7e53dd09b0a8116ccf5c3265c7c7fce13c980747525be76902aef36a514a", size = 68903, upload-time = "2025-12-06T13:22:31.29Z" }, - { url = "https://files.pythonhosted.org/packages/77/e1/7e59a19f8999cdefe9eb0d56bfd701dd38263b0f6fb4a4d29fce165a1b36/pybase64-1.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7528604cd69c538e1dbaafded46e9e4915a2adcd6f2a60fcef6390d87ca922ea", size = 57516, upload-time = "2025-12-06T13:22:32.395Z" }, - { url = "https://files.pythonhosted.org/packages/42/ad/f47dc7e6fe32022b176868b88b671a32dab389718c8ca905cab79280aaaf/pybase64-1.4.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:4ec645f32b50593879031e09158f8681a1db9f5df0f72af86b3969a1c5d1fa2b", size = 54533, upload-time = "2025-12-06T13:22:33.457Z" }, - { url = "https://files.pythonhosted.org/packages/7c/9a/7ab312b5a324833953b00e47b23eb4f83d45bd5c5c854b4b4e51b2a0cf5b/pybase64-1.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:634a000c5b3485ccc18bb9b244e0124f74b6fbc7f43eade815170237a7b34c64", size = 57187, upload-time = "2025-12-06T13:22:34.566Z" }, - { url = "https://files.pythonhosted.org/packages/2c/84/80acab1fcbaaae103e6b862ef5019192c8f2cd8758433595a202179a0d1d/pybase64-1.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:309ea32ad07639a485580af1be0ad447a434deb1924e76adced63ac2319cfe15", size = 57730, upload-time = "2025-12-06T13:22:35.581Z" }, - { url = "https://files.pythonhosted.org/packages/1f/24/84256d472400ea3163d7d69c44bb7e2e1027f0f1d4d20c47629a7dc4578e/pybase64-1.4.3-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:d10d517566b748d3f25f6ac7162af779360c1c6426ad5f962927ee205990d27c", size = 53036, upload-time = "2025-12-06T13:22:36.621Z" }, - { url = "https://files.pythonhosted.org/packages/a3/0f/33aecbed312ee0431798a73fa25e00dedbffdd91389ee23121fed397c550/pybase64-1.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a74cc0f4d835400857cc5c6d27ec854f7949491e07a04e6d66e2137812831f4c", size = 56321, upload-time = "2025-12-06T13:22:37.7Z" }, - { url = "https://files.pythonhosted.org/packages/dc/1c/a341b050746658cbec8cab3c733aeb3ef52ce8f11e60d0d47adbdf729ebf/pybase64-1.4.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1b591d774ac09d5eb73c156a03277cb271438fbd8042bae4109ff3a827cd218c", size = 50114, upload-time = "2025-12-06T13:22:38.752Z" }, - { url = "https://files.pythonhosted.org/packages/ba/d3/f7e6680ae6dc4ddff39112ad66e0fa6b2ec346e73881bafc08498c560bc0/pybase64-1.4.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5eb588d35a04302ef6157d17db62354a787ac6f8b1585dd0b90c33d63a97a550", size = 66570, upload-time = "2025-12-06T13:22:40.221Z" }, - { url = "https://files.pythonhosted.org/packages/4c/71/774748eecc7fe23869b7e5df028e3c4c2efa16b506b83ea3fa035ea95dc2/pybase64-1.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:df8b122d5be2c96962231cc4831d9c2e1eae6736fb12850cec4356d8b06fe6f8", size = 55700, upload-time = "2025-12-06T13:22:41.289Z" }, - { url = "https://files.pythonhosted.org/packages/b3/91/dd15075bb2fe0086193e1cd4bad80a43652c38d8a572f9218d46ba721802/pybase64-1.4.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:31b7a85c661fc591bbcce82fb8adaebe2941e6a83b08444b0957b77380452a4b", size = 52491, upload-time = "2025-12-06T13:22:42.628Z" }, - { url = "https://files.pythonhosted.org/packages/7b/27/f357d63ea3774c937fc47160e040419ed528827aa3d4306d5ec9826259c0/pybase64-1.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e6d7beaae65979fef250e25e66cf81c68a8f81910bcda1a2f43297ab486a7e4e", size = 53957, upload-time = "2025-12-06T13:22:44.615Z" }, - { url = "https://files.pythonhosted.org/packages/b3/c3/243693771701a54e67ff5ccbf4c038344f429613f5643169a7befc51f007/pybase64-1.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4a6276bc3a3962d172a2b5aba544d89881c4037ea954517b86b00892c703d007", size = 68422, upload-time = "2025-12-06T13:22:45.641Z" }, - { url = "https://files.pythonhosted.org/packages/75/95/f987081bf6bc1d1eda3012dae1b06ad427732ef9933a632cb8b58f9917f8/pybase64-1.4.3-cp310-cp310-win32.whl", hash = "sha256:4bdd07ef017515204ee6eaab17e1ad05f83c0ccb5af8ae24a0fe6d9cb5bb0b7a", size = 33622, upload-time = "2025-12-06T13:22:47.348Z" }, - { url = "https://files.pythonhosted.org/packages/79/28/c169a769fe90128f16d394aad87b2096dd4bf2f035ae0927108a46b617df/pybase64-1.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:5db0b6bbda15110db2740c61970a8fda3bf9c93c3166a3f57f87c7865ed1125c", size = 35799, upload-time = "2025-12-06T13:22:48.731Z" }, - { url = "https://files.pythonhosted.org/packages/ab/f2/bdbe6af0bd4f3fe5bc70e77ead7f7d523bb9d3ca3ad50ac42b9adbb9ca14/pybase64-1.4.3-cp310-cp310-win_arm64.whl", hash = "sha256:f96367dfc82598569aa02b1103ebd419298293e59e1151abda2b41728703284b", size = 31158, upload-time = "2025-12-06T13:22:50.021Z" }, - { url = "https://files.pythonhosted.org/packages/2b/63/21e981e9d3f1f123e0b0ee2130112b1956cad9752309f574862c7ae77c08/pybase64-1.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:70b0d4a4d54e216ce42c2655315378b8903933ecfa32fced453989a92b4317b2", size = 38237, upload-time = "2025-12-06T13:22:52.159Z" }, - { url = "https://files.pythonhosted.org/packages/92/fb/3f448e139516404d2a3963915cc10dc9dde7d3a67de4edba2f827adfef17/pybase64-1.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8127f110cdee7a70e576c5c9c1d4e17e92e76c191869085efbc50419f4ae3c72", size = 31673, upload-time = "2025-12-06T13:22:53.241Z" }, - { url = "https://files.pythonhosted.org/packages/3c/fb/bb06a5b9885e7d853ac1e801c4d8abfdb4c8506deee33e53d55aa6690e67/pybase64-1.4.3-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f9ef0388878bc15a084bd9bf73ec1b2b4ee513d11009b1506375e10a7aae5032", size = 68331, upload-time = "2025-12-06T13:22:54.197Z" }, - { url = "https://files.pythonhosted.org/packages/64/15/8d60b9ec5e658185fc2ee3333e01a6e30d717cf677b24f47cbb3a859d13c/pybase64-1.4.3-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95a57cccf106352a72ed8bc8198f6820b16cc7d55aa3867a16dea7011ae7c218", size = 71370, upload-time = "2025-12-06T13:22:55.517Z" }, - { url = "https://files.pythonhosted.org/packages/ac/29/a3e5c1667cc8c38d025a4636855de0fc117fc62e2afeb033a3c6f12c6a22/pybase64-1.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cd1c47dfceb9c7bd3de210fb4e65904053ed2d7c9dce6d107f041ff6fbd7e21", size = 59834, upload-time = "2025-12-06T13:22:56.682Z" }, - { url = "https://files.pythonhosted.org/packages/a9/00/8ffcf9810bd23f3984698be161cf7edba656fd639b818039a7be1d6405d4/pybase64-1.4.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:9fe9922698f3e2f72874b26890d53a051c431d942701bb3a37aae94da0b12107", size = 56652, upload-time = "2025-12-06T13:22:57.724Z" }, - { url = "https://files.pythonhosted.org/packages/81/62/379e347797cdea4ab686375945bc77ad8d039c688c0d4d0cfb09d247beb9/pybase64-1.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:af5f4bd29c86b59bb4375e0491d16ec8a67548fa99c54763aaedaf0b4b5a6632", size = 59382, upload-time = "2025-12-06T13:22:58.758Z" }, - { url = "https://files.pythonhosted.org/packages/c6/f2/9338ffe2f487086f26a2c8ca175acb3baa86fce0a756ff5670a0822bb877/pybase64-1.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c302f6ca7465262908131411226e02100f488f531bb5e64cb901aa3f439bccd9", size = 59990, upload-time = "2025-12-06T13:23:01.007Z" }, - { url = "https://files.pythonhosted.org/packages/f9/a4/85a6142b65b4df8625b337727aa81dc199642de3d09677804141df6ee312/pybase64-1.4.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:2f3f439fa4d7fde164ebbbb41968db7d66b064450ab6017c6c95cef0afa2b349", size = 54923, upload-time = "2025-12-06T13:23:02.369Z" }, - { url = "https://files.pythonhosted.org/packages/ac/00/e40215d25624012bf5b7416ca37f168cb75f6dd15acdb91ea1f2ea4dc4e7/pybase64-1.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a23c6866551043f8b681a5e1e0d59469148b2920a3b4fc42b1275f25ea4217a", size = 58664, upload-time = "2025-12-06T13:23:03.378Z" }, - { url = "https://files.pythonhosted.org/packages/b0/73/d7e19a63e795c13837f2356268d95dc79d1180e756f57ced742a1e52fdeb/pybase64-1.4.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:56e6526f8565642abc5f84338cc131ce298a8ccab696b19bdf76fa6d7dc592ef", size = 52338, upload-time = "2025-12-06T13:23:04.458Z" }, - { url = "https://files.pythonhosted.org/packages/f2/32/3c746d7a310b69bdd9df77ffc85c41b80bce00a774717596f869b0d4a20e/pybase64-1.4.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6a792a8b9d866ffa413c9687d9b611553203753987a3a582d68cbc51cf23da45", size = 68993, upload-time = "2025-12-06T13:23:05.526Z" }, - { url = "https://files.pythonhosted.org/packages/5d/b3/63cec68f9d6f6e4c0b438d14e5f1ef536a5fe63ce14b70733ac5e31d7ab8/pybase64-1.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:62ad29a5026bb22cfcd1ca484ec34b0a5ced56ddba38ceecd9359b2818c9c4f9", size = 58055, upload-time = "2025-12-06T13:23:06.931Z" }, - { url = "https://files.pythonhosted.org/packages/d5/cb/7acf7c3c06f9692093c07f109668725dc37fb9a3df0fa912b50add645195/pybase64-1.4.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11b9d1d2d32ec358c02214363b8fc3651f6be7dd84d880ecd597a6206a80e121", size = 54430, upload-time = "2025-12-06T13:23:07.936Z" }, - { url = "https://files.pythonhosted.org/packages/33/39/4eb33ff35d173bfff4002e184ce8907f5d0a42d958d61cd9058ef3570179/pybase64-1.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0aebaa7f238caa0a0d373616016e2040c6c879ebce3ba7ab3c59029920f13640", size = 56272, upload-time = "2025-12-06T13:23:09.253Z" }, - { url = "https://files.pythonhosted.org/packages/19/97/a76d65c375a254e65b730c6f56bf528feca91305da32eceab8bcc08591e6/pybase64-1.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e504682b20c63c2b0c000e5f98a80ea867f8d97642e042a5a39818e44ba4d599", size = 70904, upload-time = "2025-12-06T13:23:10.336Z" }, - { url = "https://files.pythonhosted.org/packages/5e/2c/8338b6d3da3c265002839e92af0a80d6db88385c313c73f103dfb800c857/pybase64-1.4.3-cp311-cp311-win32.whl", hash = "sha256:e9a8b81984e3c6fb1db9e1614341b0a2d98c0033d693d90c726677db1ffa3a4c", size = 33639, upload-time = "2025-12-06T13:23:11.9Z" }, - { url = "https://files.pythonhosted.org/packages/39/dc/32efdf2f5927e5449cc341c266a1bbc5fecd5319a8807d9c5405f76e6d02/pybase64-1.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:a90a8fa16a901fabf20de824d7acce07586e6127dc2333f1de05f73b1f848319", size = 35797, upload-time = "2025-12-06T13:23:13.174Z" }, - { url = "https://files.pythonhosted.org/packages/da/59/eda4f9cb0cbce5a45f0cd06131e710674f8123a4d570772c5b9694f88559/pybase64-1.4.3-cp311-cp311-win_arm64.whl", hash = "sha256:61d87de5bc94d143622e94390ec3e11b9c1d4644fe9be3a81068ab0f91056f59", size = 31160, upload-time = "2025-12-06T13:23:15.696Z" }, - { url = "https://files.pythonhosted.org/packages/86/a7/efcaa564f091a2af7f18a83c1c4875b1437db56ba39540451dc85d56f653/pybase64-1.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:18d85e5ab8b986bb32d8446aca6258ed80d1bafe3603c437690b352c648f5967", size = 38167, upload-time = "2025-12-06T13:23:16.821Z" }, - { url = "https://files.pythonhosted.org/packages/db/c7/c7ad35adff2d272bf2930132db2b3eea8c44bb1b1f64eb9b2b8e57cde7b4/pybase64-1.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3f5791a3491d116d0deaf4d83268f48792998519698f8751efb191eac84320e9", size = 31673, upload-time = "2025-12-06T13:23:17.835Z" }, - { url = "https://files.pythonhosted.org/packages/43/1b/9a8cab0042b464e9a876d5c65fe5127445a2436da36fda64899b119b1a1b/pybase64-1.4.3-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f0b3f200c3e06316f6bebabd458b4e4bcd4c2ca26af7c0c766614d91968dee27", size = 68210, upload-time = "2025-12-06T13:23:18.813Z" }, - { url = "https://files.pythonhosted.org/packages/62/f7/965b79ff391ad208b50e412b5d3205ccce372a2d27b7218ae86d5295b105/pybase64-1.4.3-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb632edfd132b3eaf90c39c89aa314beec4e946e210099b57d40311f704e11d4", size = 71599, upload-time = "2025-12-06T13:23:20.195Z" }, - { url = "https://files.pythonhosted.org/packages/03/4b/a3b5175130b3810bbb8ccfa1edaadbd3afddb9992d877c8a1e2f274b476e/pybase64-1.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:356ef1d74648ce997f5a777cf8f1aefecc1c0b4fe6201e0ef3ec8a08170e1b54", size = 59922, upload-time = "2025-12-06T13:23:21.487Z" }, - { url = "https://files.pythonhosted.org/packages/da/5d/c38d1572027fc601b62d7a407721688b04b4d065d60ca489912d6893e6cf/pybase64-1.4.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:c48361f90db32bacaa5518419d4eb9066ba558013aaf0c7781620279ecddaeb9", size = 56712, upload-time = "2025-12-06T13:23:22.77Z" }, - { url = "https://files.pythonhosted.org/packages/e7/d4/4e04472fef485caa8f561d904d4d69210a8f8fc1608ea15ebd9012b92655/pybase64-1.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:702bcaa16ae02139d881aeaef5b1c8ffb4a3fae062fe601d1e3835e10310a517", size = 59300, upload-time = "2025-12-06T13:23:24.543Z" }, - { url = "https://files.pythonhosted.org/packages/86/e7/16e29721b86734b881d09b7e23dfd7c8408ad01a4f4c7525f3b1088e25ec/pybase64-1.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:53d0ffe1847b16b647c6413d34d1de08942b7724273dd57e67dcbdb10c574045", size = 60278, upload-time = "2025-12-06T13:23:25.608Z" }, - { url = "https://files.pythonhosted.org/packages/b1/02/18515f211d7c046be32070709a8efeeef8a0203de4fd7521e6b56404731b/pybase64-1.4.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9a1792e8b830a92736dae58f0c386062eb038dfe8004fb03ba33b6083d89cd43", size = 54817, upload-time = "2025-12-06T13:23:26.633Z" }, - { url = "https://files.pythonhosted.org/packages/e7/be/14e29d8e1a481dbff151324c96dd7b5d2688194bb65dc8a00ca0e1ad1e86/pybase64-1.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1d468b1b1ac5ad84875a46eaa458663c3721e8be5f155ade356406848d3701f6", size = 58611, upload-time = "2025-12-06T13:23:27.684Z" }, - { url = "https://files.pythonhosted.org/packages/b4/8a/a2588dfe24e1bbd742a554553778ab0d65fdf3d1c9a06d10b77047d142aa/pybase64-1.4.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e97b7bdbd62e71898cd542a6a9e320d9da754ff3ebd02cb802d69087ee94d468", size = 52404, upload-time = "2025-12-06T13:23:28.714Z" }, - { url = "https://files.pythonhosted.org/packages/27/fc/afcda7445bebe0cbc38cafdd7813234cdd4fc5573ff067f1abf317bb0cec/pybase64-1.4.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b33aeaa780caaa08ffda87fc584d5eab61e3d3bbb5d86ead02161dc0c20d04bc", size = 68817, upload-time = "2025-12-06T13:23:30.079Z" }, - { url = "https://files.pythonhosted.org/packages/d3/3a/87c3201e555ed71f73e961a787241a2438c2bbb2ca8809c29ddf938a3157/pybase64-1.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c0efcf78f11cf866bed49caa7b97552bc4855a892f9cc2372abcd3ed0056f0d", size = 57854, upload-time = "2025-12-06T13:23:31.17Z" }, - { url = "https://files.pythonhosted.org/packages/fd/7d/931c2539b31a7b375e7d595b88401eeb5bd6c5ce1059c9123f9b608aaa14/pybase64-1.4.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:66e3791f2ed725a46593f8bd2761ff37d01e2cdad065b1dceb89066f476e50c6", size = 54333, upload-time = "2025-12-06T13:23:32.422Z" }, - { url = "https://files.pythonhosted.org/packages/de/5e/537601e02cc01f27e9d75f440f1a6095b8df44fc28b1eef2cd739aea8cec/pybase64-1.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:72bb0b6bddadab26e1b069bb78e83092711a111a80a0d6b9edcb08199ad7299b", size = 56492, upload-time = "2025-12-06T13:23:33.515Z" }, - { url = "https://files.pythonhosted.org/packages/96/97/2a2e57acf8f5c9258d22aba52e71f8050e167b29ed2ee1113677c1b600c1/pybase64-1.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5b3365dbcbcdb0a294f0f50af0c0a16b27a232eddeeb0bceeefd844ef30d2a23", size = 70974, upload-time = "2025-12-06T13:23:36.27Z" }, - { url = "https://files.pythonhosted.org/packages/75/2e/a9e28941c6dab6f06e6d3f6783d3373044be9b0f9a9d3492c3d8d2260ac0/pybase64-1.4.3-cp312-cp312-win32.whl", hash = "sha256:7bca1ed3a5df53305c629ca94276966272eda33c0d71f862d2d3d043f1e1b91a", size = 33686, upload-time = "2025-12-06T13:23:37.848Z" }, - { url = "https://files.pythonhosted.org/packages/83/e3/507ab649d8c3512c258819c51d25c45d6e29d9ca33992593059e7b646a33/pybase64-1.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:9f2da8f56d9b891b18b4daf463a0640eae45a80af548ce435be86aa6eff3603b", size = 35833, upload-time = "2025-12-06T13:23:38.877Z" }, - { url = "https://files.pythonhosted.org/packages/bc/8a/6eba66cd549a2fc74bb4425fd61b839ba0ab3022d3c401b8a8dc2cc00c7a/pybase64-1.4.3-cp312-cp312-win_arm64.whl", hash = "sha256:0631d8a2d035de03aa9bded029b9513e1fee8ed80b7ddef6b8e9389ffc445da0", size = 31185, upload-time = "2025-12-06T13:23:39.908Z" }, - { url = "https://files.pythonhosted.org/packages/3a/50/b7170cb2c631944388fe2519507fe3835a4054a6a12a43f43781dae82be1/pybase64-1.4.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:ea4b785b0607d11950b66ce7c328f452614aefc9c6d3c9c28bae795dc7f072e1", size = 33901, upload-time = "2025-12-06T13:23:40.951Z" }, - { url = "https://files.pythonhosted.org/packages/48/8b/69f50578e49c25e0a26e3ee72c39884ff56363344b79fc3967f5af420ed6/pybase64-1.4.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:6a10b6330188c3026a8b9c10e6b9b3f2e445779cf16a4c453d51a072241c65a2", size = 40807, upload-time = "2025-12-06T13:23:42.006Z" }, - { url = "https://files.pythonhosted.org/packages/5c/8d/20b68f11adfc4c22230e034b65c71392e3e338b413bf713c8945bd2ccfb3/pybase64-1.4.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:27fdff227a0c0e182e0ba37a99109645188978b920dfb20d8b9c17eeee370d0d", size = 30932, upload-time = "2025-12-06T13:23:43.348Z" }, - { url = "https://files.pythonhosted.org/packages/f7/79/b1b550ac6bff51a4880bf6e089008b2e1ca16f2c98db5e039a08ac3ad157/pybase64-1.4.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2a8204f1fdfec5aa4184249b51296c0de95445869920c88123978304aad42df1", size = 31394, upload-time = "2025-12-06T13:23:44.317Z" }, - { url = "https://files.pythonhosted.org/packages/82/70/b5d7c5932bf64ee1ec5da859fbac981930b6a55d432a603986c7f509c838/pybase64-1.4.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:874fc2a3777de6baf6aa921a7aa73b3be98295794bea31bd80568a963be30767", size = 38078, upload-time = "2025-12-06T13:23:45.348Z" }, - { url = "https://files.pythonhosted.org/packages/56/fe/e66fe373bce717c6858427670736d54297938dad61c5907517ab4106bd90/pybase64-1.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2dc64a94a9d936b8e3449c66afabbaa521d3cc1a563d6bbaaa6ffa4535222e4b", size = 38158, upload-time = "2025-12-06T13:23:46.872Z" }, - { url = "https://files.pythonhosted.org/packages/80/a9/b806ed1dcc7aed2ea3dd4952286319e6f3a8b48615c8118f453948e01999/pybase64-1.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e48f86de1c145116ccf369a6e11720ce696c2ec02d285f440dfb57ceaa0a6cb4", size = 31672, upload-time = "2025-12-06T13:23:47.88Z" }, - { url = "https://files.pythonhosted.org/packages/1c/c9/24b3b905cf75e23a9a4deaf203b35ffcb9f473ac0e6d8257f91a05dfce62/pybase64-1.4.3-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:1d45c8fe8fe82b65c36b227bb4a2cf623d9ada16bed602ce2d3e18c35285b72a", size = 68244, upload-time = "2025-12-06T13:23:49.026Z" }, - { url = "https://files.pythonhosted.org/packages/f8/cd/d15b0c3e25e5859fab0416dc5b96d34d6bd2603c1c96a07bb2202b68ab92/pybase64-1.4.3-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ad70c26ba091d8f5167e9d4e1e86a0483a5414805cdb598a813db635bd3be8b8", size = 71620, upload-time = "2025-12-06T13:23:50.081Z" }, - { url = "https://files.pythonhosted.org/packages/0d/31/4ca953cc3dcde2b3711d6bfd70a6f4ad2ca95a483c9698076ba605f1520f/pybase64-1.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e98310b7c43145221e7194ac9fa7fffc84763c87bfc5e2f59f9f92363475bdc1", size = 59930, upload-time = "2025-12-06T13:23:51.68Z" }, - { url = "https://files.pythonhosted.org/packages/60/55/e7f7bdcd0fd66e61dda08db158ffda5c89a306bbdaaf5a062fbe4e48f4a1/pybase64-1.4.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:398685a76034e91485a28aeebcb49e64cd663212fd697b2497ac6dfc1df5e671", size = 56425, upload-time = "2025-12-06T13:23:52.732Z" }, - { url = "https://files.pythonhosted.org/packages/cb/65/b592c7f921e51ca1aca3af5b0d201a98666d0a36b930ebb67e7c2ed27395/pybase64-1.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7e46400a6461187ccb52ed75b0045d937529e801a53a9cd770b350509f9e4d50", size = 59327, upload-time = "2025-12-06T13:23:53.856Z" }, - { url = "https://files.pythonhosted.org/packages/23/95/1613d2fb82dbb1548595ad4179f04e9a8451bfa18635efce18b631eabe3f/pybase64-1.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:1b62b9f2f291d94f5e0b76ab499790b7dcc78a009d4ceea0b0428770267484b6", size = 60294, upload-time = "2025-12-06T13:23:54.937Z" }, - { url = "https://files.pythonhosted.org/packages/9d/73/40431f37f7d1b3eab4673e7946ff1e8f5d6bd425ec257e834dae8a6fc7b0/pybase64-1.4.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:f30ceb5fa4327809dede614be586efcbc55404406d71e1f902a6fdcf322b93b2", size = 54858, upload-time = "2025-12-06T13:23:56.031Z" }, - { url = "https://files.pythonhosted.org/packages/a7/84/f6368bcaf9f743732e002a9858646fd7a54f428490d427dd6847c5cfe89e/pybase64-1.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0d5f18ed53dfa1d4cf8b39ee542fdda8e66d365940e11f1710989b3cf4a2ed66", size = 58629, upload-time = "2025-12-06T13:23:57.12Z" }, - { url = "https://files.pythonhosted.org/packages/43/75/359532f9adb49c6b546cafc65c46ed75e2ccc220d514ba81c686fbd83965/pybase64-1.4.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:119d31aa4b58b85a8ebd12b63c07681a138c08dfc2fe5383459d42238665d3eb", size = 52448, upload-time = "2025-12-06T13:23:58.298Z" }, - { url = "https://files.pythonhosted.org/packages/92/6c/ade2ba244c3f33ed920a7ed572ad772eb0b5f14480b72d629d0c9e739a40/pybase64-1.4.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3cf0218b0e2f7988cf7d738a73b6a1d14f3be6ce249d7c0f606e768366df2cce", size = 68841, upload-time = "2025-12-06T13:23:59.886Z" }, - { url = "https://files.pythonhosted.org/packages/a0/51/b345139cd236be382f2d4d4453c21ee6299e14d2f759b668e23080f8663f/pybase64-1.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:12f4ee5e988bc5c0c1106b0d8fc37fb0508f12dab76bac1b098cb500d148da9d", size = 57910, upload-time = "2025-12-06T13:24:00.994Z" }, - { url = "https://files.pythonhosted.org/packages/1a/b8/9f84bdc4f1c4f0052489396403c04be2f9266a66b70c776001eaf0d78c1f/pybase64-1.4.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:937826bc7b6b95b594a45180e81dd4d99bd4dd4814a443170e399163f7ff3fb6", size = 54335, upload-time = "2025-12-06T13:24:02.046Z" }, - { url = "https://files.pythonhosted.org/packages/d0/c7/be63b617d284de46578a366da77ede39c8f8e815ed0d82c7c2acca560fab/pybase64-1.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:88995d1460971ef80b13e3e007afbe4b27c62db0508bc7250a2ab0a0b4b91362", size = 56486, upload-time = "2025-12-06T13:24:03.141Z" }, - { url = "https://files.pythonhosted.org/packages/5e/96/f252c8f9abd6ded3ef1ccd3cdbb8393a33798007f761b23df8de1a2480e6/pybase64-1.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:72326fe163385ed3e1e806dd579d47fde5d8a59e51297a60fc4e6cbc1b4fc4ed", size = 70978, upload-time = "2025-12-06T13:24:04.221Z" }, - { url = "https://files.pythonhosted.org/packages/af/51/0f5714af7aeef96e30f968e4371d75ad60558aaed3579d7c6c8f1c43c18a/pybase64-1.4.3-cp313-cp313-win32.whl", hash = "sha256:b1623730c7892cf5ed0d6355e375416be6ef8d53ab9b284f50890443175c0ac3", size = 33684, upload-time = "2025-12-06T13:24:05.29Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ad/0cea830a654eb08563fb8214150ef57546ece1cc421c09035f0e6b0b5ea9/pybase64-1.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:8369887590f1646a5182ca2fb29252509da7ae31d4923dbb55d3e09da8cc4749", size = 35832, upload-time = "2025-12-06T13:24:06.35Z" }, - { url = "https://files.pythonhosted.org/packages/b4/0d/eec2a8214989c751bc7b4cad1860eb2c6abf466e76b77508c0f488c96a37/pybase64-1.4.3-cp313-cp313-win_arm64.whl", hash = "sha256:860b86bca71e5f0237e2ab8b2d9c4c56681f3513b1bf3e2117290c1963488390", size = 31175, upload-time = "2025-12-06T13:24:07.419Z" }, - { url = "https://files.pythonhosted.org/packages/db/c9/e23463c1a2913686803ef76b1a5ae7e6fac868249a66e48253d17ad7232c/pybase64-1.4.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:eb51db4a9c93215135dccd1895dca078e8785c357fabd983c9f9a769f08989a9", size = 38497, upload-time = "2025-12-06T13:24:08.873Z" }, - { url = "https://files.pythonhosted.org/packages/71/83/343f446b4b7a7579bf6937d2d013d82f1a63057cf05558e391ab6039d7db/pybase64-1.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a03ef3f529d85fd46b89971dfb00c634d53598d20ad8908fb7482955c710329d", size = 32076, upload-time = "2025-12-06T13:24:09.975Z" }, - { url = "https://files.pythonhosted.org/packages/46/fc/cb64964c3b29b432f54d1bce5e7691d693e33bbf780555151969ffd95178/pybase64-1.4.3-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:2e745f2ce760c6cf04d8a72198ef892015ddb89f6ceba489e383518ecbdb13ab", size = 72317, upload-time = "2025-12-06T13:24:11.129Z" }, - { url = "https://files.pythonhosted.org/packages/0a/b7/fab2240da6f4e1ad46f71fa56ec577613cf5df9dce2d5b4cfaa4edd0e365/pybase64-1.4.3-cp313-cp313t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fac217cd9de8581a854b0ac734c50fd1fa4b8d912396c1fc2fce7c230efe3a7", size = 75534, upload-time = "2025-12-06T13:24:12.433Z" }, - { url = "https://files.pythonhosted.org/packages/91/3b/3e2f2b6e68e3d83ddb9fa799f3548fb7449765daec9bbd005a9fbe296d7f/pybase64-1.4.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:da1ee8fa04b283873de2d6e8fa5653e827f55b86bdf1a929c5367aaeb8d26f8a", size = 65399, upload-time = "2025-12-06T13:24:13.928Z" }, - { url = "https://files.pythonhosted.org/packages/6b/08/476ac5914c3b32e0274a2524fc74f01cbf4f4af4513d054e41574eb018f6/pybase64-1.4.3-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:b0bf8e884ee822ca7b1448eeb97fa131628fe0ff42f60cae9962789bd562727f", size = 60487, upload-time = "2025-12-06T13:24:15.177Z" }, - { url = "https://files.pythonhosted.org/packages/f1/b8/618a92915330cc9cba7880299b546a1d9dab1a21fd6c0292ee44a4fe608c/pybase64-1.4.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1bf749300382a6fd1f4f255b183146ef58f8e9cb2f44a077b3a9200dfb473a77", size = 63959, upload-time = "2025-12-06T13:24:16.854Z" }, - { url = "https://files.pythonhosted.org/packages/a5/52/af9d8d051652c3051862c442ec3861259c5cdb3fc69774bc701470bd2a59/pybase64-1.4.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:153a0e42329b92337664cfc356f2065248e6c9a1bd651bbcd6dcaf15145d3f06", size = 64874, upload-time = "2025-12-06T13:24:18.328Z" }, - { url = "https://files.pythonhosted.org/packages/e4/51/5381a7adf1f381bd184d33203692d3c57cf8ae9f250f380c3fecbdbe554b/pybase64-1.4.3-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:86ee56ac7f2184ca10217ed1c655c1a060273e233e692e9086da29d1ae1768db", size = 58572, upload-time = "2025-12-06T13:24:19.417Z" }, - { url = "https://files.pythonhosted.org/packages/e0/f0/578ee4ffce5818017de4fdf544e066c225bc435e73eb4793cde28a689d0b/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0e71a4db76726bf830b47477e7d830a75c01b2e9b01842e787a0836b0ba741e3", size = 63636, upload-time = "2025-12-06T13:24:20.497Z" }, - { url = "https://files.pythonhosted.org/packages/b9/ad/8ae94814bf20159ea06310b742433e53d5820aa564c9fdf65bf2d79f8799/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2ba7799ec88540acd9861b10551d24656ca3c2888ecf4dba2ee0a71544a8923f", size = 56193, upload-time = "2025-12-06T13:24:21.559Z" }, - { url = "https://files.pythonhosted.org/packages/d1/31/6438cfcc3d3f0fa84d229fa125c243d5094e72628e525dfefadf3bcc6761/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2860299e4c74315f5951f0cf3e72ba0f201c3356c8a68f95a3ab4e620baf44e9", size = 72655, upload-time = "2025-12-06T13:24:22.673Z" }, - { url = "https://files.pythonhosted.org/packages/a3/0d/2bbc9e9c3fc12ba8a6e261482f03a544aca524f92eae0b4908c0a10ba481/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:bb06015db9151f0c66c10aae8e3603adab6b6cd7d1f7335a858161d92fc29618", size = 62471, upload-time = "2025-12-06T13:24:23.8Z" }, - { url = "https://files.pythonhosted.org/packages/2c/0b/34d491e7f49c1dbdb322ea8da6adecda7c7cd70b6644557c6e4ca5c6f7c7/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:242512a070817272865d37c8909059f43003b81da31f616bb0c391ceadffe067", size = 58119, upload-time = "2025-12-06T13:24:24.994Z" }, - { url = "https://files.pythonhosted.org/packages/ce/17/c21d0cde2a6c766923ae388fc1f78291e1564b0d38c814b5ea8a0e5e081c/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5d8277554a12d3e3eed6180ebda62786bf9fc8d7bb1ee00244258f4a87ca8d20", size = 60791, upload-time = "2025-12-06T13:24:26.046Z" }, - { url = "https://files.pythonhosted.org/packages/92/b2/eaa67038916a48de12b16f4c384bcc1b84b7ec731b23613cb05f27673294/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f40b7ddd698fc1e13a4b64fbe405e4e0e1279e8197e37050e24154655f5f7c4e", size = 74701, upload-time = "2025-12-06T13:24:27.466Z" }, - { url = "https://files.pythonhosted.org/packages/42/10/abb7757c330bb869ebb95dab0c57edf5961ffbd6c095c8209cbbf75d117d/pybase64-1.4.3-cp313-cp313t-win32.whl", hash = "sha256:46d75c9387f354c5172582a9eaae153b53a53afeb9c19fcf764ea7038be3bd8b", size = 33965, upload-time = "2025-12-06T13:24:28.548Z" }, - { url = "https://files.pythonhosted.org/packages/63/a0/2d4e5a59188e9e6aed0903d580541aaea72dcbbab7bf50fb8b83b490b6c3/pybase64-1.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:d7344625591d281bec54e85cbfdab9e970f6219cac1570f2aa140b8c942ccb81", size = 36207, upload-time = "2025-12-06T13:24:29.646Z" }, - { url = "https://files.pythonhosted.org/packages/1f/05/95b902e8f567b4d4b41df768ccc438af618f8d111e54deaf57d2df46bd76/pybase64-1.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:28a3c60c55138e0028313f2eccd321fec3c4a0be75e57a8d3eb883730b1b0880", size = 31505, upload-time = "2025-12-06T13:24:30.687Z" }, - { url = "https://files.pythonhosted.org/packages/e4/80/4bd3dff423e5a91f667ca41982dc0b79495b90ec0c0f5d59aca513e50f8c/pybase64-1.4.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:015bb586a1ea1467f69d57427abe587469392215f59db14f1f5c39b52fdafaf5", size = 33835, upload-time = "2025-12-06T13:24:31.767Z" }, - { url = "https://files.pythonhosted.org/packages/45/60/a94d94cc1e3057f602e0b483c9ebdaef40911d84a232647a2fe593ab77bb/pybase64-1.4.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:d101e3a516f837c3dcc0e5a0b7db09582ebf99ed670865223123fb2e5839c6c0", size = 40673, upload-time = "2025-12-06T13:24:32.82Z" }, - { url = "https://files.pythonhosted.org/packages/e3/71/cf62b261d431857e8e054537a5c3c24caafa331de30daede7b2c6c558501/pybase64-1.4.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8f183ac925a48046abe047360fe3a1b28327afb35309892132fe1915d62fb282", size = 30939, upload-time = "2025-12-06T13:24:34.001Z" }, - { url = "https://files.pythonhosted.org/packages/24/3e/d12f92a3c1f7c6ab5d53c155bff9f1084ba997a37a39a4f781ccba9455f3/pybase64-1.4.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30bf3558e24dcce4da5248dcf6d73792adfcf4f504246967e9db155be4c439ad", size = 31401, upload-time = "2025-12-06T13:24:35.11Z" }, - { url = "https://files.pythonhosted.org/packages/9b/3d/9c27440031fea0d05146f8b70a460feb95d8b4e3d9ca8f45c972efb4c3d3/pybase64-1.4.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a674b419de318d2ce54387dd62646731efa32b4b590907800f0bd40675c1771d", size = 38075, upload-time = "2025-12-06T13:24:36.53Z" }, - { url = "https://files.pythonhosted.org/packages/4b/d4/6c0e0cf0efd53c254173fbcd84a3d8fcbf5e0f66622473da425becec32a5/pybase64-1.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:720104fd7303d07bac302be0ff8f7f9f126f2f45c1edb4f48fdb0ff267e69fe1", size = 38257, upload-time = "2025-12-06T13:24:38.049Z" }, - { url = "https://files.pythonhosted.org/packages/50/eb/27cb0b610d5cd70f5ad0d66c14ad21c04b8db930f7139818e8fbdc14df4d/pybase64-1.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:83f1067f73fa5afbc3efc0565cecc6ed53260eccddef2ebe43a8ce2b99ea0e0a", size = 31685, upload-time = "2025-12-06T13:24:40.327Z" }, - { url = "https://files.pythonhosted.org/packages/db/26/b136a4b65e5c94ff06217f7726478df3f31ab1c777c2c02cf698e748183f/pybase64-1.4.3-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:b51204d349a4b208287a8aa5b5422be3baa88abf6cc8ff97ccbda34919bbc857", size = 68460, upload-time = "2025-12-06T13:24:41.735Z" }, - { url = "https://files.pythonhosted.org/packages/68/6d/84ce50e7ee1ae79984d689e05a9937b2460d4efa1e5b202b46762fb9036c/pybase64-1.4.3-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:30f2fd53efecbdde4bdca73a872a68dcb0d1bf8a4560c70a3e7746df973e1ef3", size = 71688, upload-time = "2025-12-06T13:24:42.908Z" }, - { url = "https://files.pythonhosted.org/packages/e3/57/6743e420416c3ff1b004041c85eb0ebd9c50e9cf05624664bfa1dc8b5625/pybase64-1.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0932b0c5cfa617091fd74f17d24549ce5de3628791998c94ba57be808078eeaf", size = 60040, upload-time = "2025-12-06T13:24:44.37Z" }, - { url = "https://files.pythonhosted.org/packages/3b/68/733324e28068a89119af2921ce548e1c607cc5c17d354690fc51c302e326/pybase64-1.4.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:acb61f5ab72bec808eb0d4ce8b87ec9f38d7d750cb89b1371c35eb8052a29f11", size = 56478, upload-time = "2025-12-06T13:24:45.815Z" }, - { url = "https://files.pythonhosted.org/packages/b5/9e/f3f4aa8cfe3357a3cdb0535b78eb032b671519d3ecc08c58c4c6b72b5a91/pybase64-1.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:2bc2d5bc15168f5c04c53bdfe5a1e543b2155f456ed1e16d7edce9ce73842021", size = 59463, upload-time = "2025-12-06T13:24:46.938Z" }, - { url = "https://files.pythonhosted.org/packages/aa/d1/53286038e1f0df1cf58abcf4a4a91b0f74ab44539c2547b6c31001ddd054/pybase64-1.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:8a7bc3cd23880bdca59758bcdd6f4ef0674f2393782763910a7466fab35ccb98", size = 60360, upload-time = "2025-12-06T13:24:48.039Z" }, - { url = "https://files.pythonhosted.org/packages/00/9a/5cc6ce95db2383d27ff4d790b8f8b46704d360d701ab77c4f655bcfaa6a7/pybase64-1.4.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:ad15acf618880d99792d71e3905b0e2508e6e331b76a1b34212fa0f11e01ad28", size = 54999, upload-time = "2025-12-06T13:24:49.547Z" }, - { url = "https://files.pythonhosted.org/packages/64/e7/c3c1d09c3d7ae79e3aa1358c6d912d6b85f29281e47aa94fc0122a415a2f/pybase64-1.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448158d417139cb4851200e5fee62677ae51f56a865d50cda9e0d61bda91b116", size = 58736, upload-time = "2025-12-06T13:24:50.641Z" }, - { url = "https://files.pythonhosted.org/packages/db/d5/0baa08e3d8119b15b588c39f0d39fd10472f0372e3c54ca44649cbefa256/pybase64-1.4.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9058c49b5a2f3e691b9db21d37eb349e62540f9f5fc4beabf8cbe3c732bead86", size = 52298, upload-time = "2025-12-06T13:24:51.791Z" }, - { url = "https://files.pythonhosted.org/packages/00/87/fc6f11474a1de7e27cd2acbb8d0d7508bda3efa73dfe91c63f968728b2a3/pybase64-1.4.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ce561724f6522907a66303aca27dce252d363fcd85884972d348f4403ba3011a", size = 69049, upload-time = "2025-12-06T13:24:53.253Z" }, - { url = "https://files.pythonhosted.org/packages/69/9d/7fb5566f669ac18b40aa5fc1c438e24df52b843c1bdc5da47d46d4c1c630/pybase64-1.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:63316560a94ac449fe86cb8b9e0a13714c659417e92e26a5cbf085cd0a0c838d", size = 57952, upload-time = "2025-12-06T13:24:54.342Z" }, - { url = "https://files.pythonhosted.org/packages/de/cc/ceb949232dbbd3ec4ee0190d1df4361296beceee9840390a63df8bc31784/pybase64-1.4.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7ecd796f2ac0be7b73e7e4e232b8c16422014de3295d43e71d2b19fd4a4f5368", size = 54484, upload-time = "2025-12-06T13:24:55.774Z" }, - { url = "https://files.pythonhosted.org/packages/a7/69/659f3c8e6a5d7b753b9c42a4bd9c42892a0f10044e9c7351a4148d413a33/pybase64-1.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d01e102a12fb2e1ed3dc11611c2818448626637857ec3994a9cf4809dfd23477", size = 56542, upload-time = "2025-12-06T13:24:57Z" }, - { url = "https://files.pythonhosted.org/packages/85/2c/29c9e6c9c82b72025f9676f9e82eb1fd2339ad038cbcbf8b9e2ac02798fc/pybase64-1.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ebff797a93c2345f22183f454fd8607a34d75eca5a3a4a969c1c75b304cee39d", size = 71045, upload-time = "2025-12-06T13:24:58.179Z" }, - { url = "https://files.pythonhosted.org/packages/b9/84/5a3dce8d7a0040a5c0c14f0fe1311cd8db872913fa04438071b26b0dac04/pybase64-1.4.3-cp314-cp314-win32.whl", hash = "sha256:28b2a1bb0828c0595dc1ea3336305cd97ff85b01c00d81cfce4f92a95fb88f56", size = 34200, upload-time = "2025-12-06T13:24:59.956Z" }, - { url = "https://files.pythonhosted.org/packages/57/bc/ce7427c12384adee115b347b287f8f3cf65860b824d74fe2c43e37e81c1f/pybase64-1.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:33338d3888700ff68c3dedfcd49f99bfc3b887570206130926791e26b316b029", size = 36323, upload-time = "2025-12-06T13:25:01.708Z" }, - { url = "https://files.pythonhosted.org/packages/9a/1b/2b8ffbe9a96eef7e3f6a5a7be75995eebfb6faaedc85b6da6b233e50c778/pybase64-1.4.3-cp314-cp314-win_arm64.whl", hash = "sha256:62725669feb5acb186458da2f9353e88ae28ef66bb9c4c8d1568b12a790dfa94", size = 31584, upload-time = "2025-12-06T13:25:02.801Z" }, - { url = "https://files.pythonhosted.org/packages/ac/d8/6824c2e6fb45b8fa4e7d92e3c6805432d5edc7b855e3e8e1eedaaf6efb7c/pybase64-1.4.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:153fe29be038948d9372c3e77ae7d1cab44e4ba7d9aaf6f064dbeea36e45b092", size = 38601, upload-time = "2025-12-06T13:25:04.222Z" }, - { url = "https://files.pythonhosted.org/packages/ea/e5/10d2b3a4ad3a4850be2704a2f70cd9c0cf55725c8885679872d3bc846c67/pybase64-1.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f7fe3decaa7c4a9e162327ec7bd81ce183d2b16f23c6d53b606649c6e0203e9e", size = 32078, upload-time = "2025-12-06T13:25:05.362Z" }, - { url = "https://files.pythonhosted.org/packages/43/04/8b15c34d3c2282f1c1b0850f1113a249401b618a382646a895170bc9b5e7/pybase64-1.4.3-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:a5ae04ea114c86eb1da1f6e18d75f19e3b5ae39cb1d8d3cd87c29751a6a22780", size = 72474, upload-time = "2025-12-06T13:25:06.434Z" }, - { url = "https://files.pythonhosted.org/packages/42/00/f34b4d11278f8fdc68bc38f694a91492aa318f7c6f1bd7396197ac0f8b12/pybase64-1.4.3-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1755b3dce3a2a5c7d17ff6d4115e8bee4a1d5aeae74469db02e47c8f477147da", size = 75706, upload-time = "2025-12-06T13:25:07.636Z" }, - { url = "https://files.pythonhosted.org/packages/bb/5d/71747d4ad7fe16df4c4c852bdbdeb1f2cf35677b48d7c34d3011a7a6ad3a/pybase64-1.4.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb852f900e27ffc4ec1896817535a0fa19610ef8875a096b59f21d0aa42ff172", size = 65589, upload-time = "2025-12-06T13:25:08.809Z" }, - { url = "https://files.pythonhosted.org/packages/49/b1/d1e82bd58805bb5a3a662864800bab83a83a36ba56e7e3b1706c708002a5/pybase64-1.4.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:9cf21ea8c70c61eddab3421fbfce061fac4f2fb21f7031383005a1efdb13d0b9", size = 60670, upload-time = "2025-12-06T13:25:10.04Z" }, - { url = "https://files.pythonhosted.org/packages/15/67/16c609b7a13d1d9fc87eca12ba2dce5e67f949eeaab61a41bddff843cbb0/pybase64-1.4.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:afff11b331fdc27692fc75e85ae083340a35105cea1a3c4552139e2f0e0d174f", size = 64194, upload-time = "2025-12-06T13:25:11.48Z" }, - { url = "https://files.pythonhosted.org/packages/3c/11/37bc724e42960f0106c2d33dc957dcec8f760c91a908cc6c0df7718bc1a8/pybase64-1.4.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9a5143df542c1ce5c1f423874b948c4d689b3f05ec571f8792286197a39ba02", size = 64984, upload-time = "2025-12-06T13:25:12.645Z" }, - { url = "https://files.pythonhosted.org/packages/6e/66/b2b962a6a480dd5dae3029becf03ea1a650d326e39bf1c44ea3db78bb010/pybase64-1.4.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:d62e9861019ad63624b4a7914dff155af1cc5d6d79df3be14edcaedb5fdad6f9", size = 58750, upload-time = "2025-12-06T13:25:13.848Z" }, - { url = "https://files.pythonhosted.org/packages/2b/15/9b6d711035e29b18b2e1c03d47f41396d803d06ef15b6c97f45b75f73f04/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:84cfd4d92668ef5766cc42a9c9474b88960ac2b860767e6e7be255c6fddbd34a", size = 63816, upload-time = "2025-12-06T13:25:15.356Z" }, - { url = "https://files.pythonhosted.org/packages/b4/21/e2901381ed0df62e2308380f30d9c4d87d6b74e33a84faed3478d33a7197/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:60fc025437f9a7c2cc45e0c19ed68ed08ba672be2c5575fd9d98bdd8f01dd61f", size = 56348, upload-time = "2025-12-06T13:25:16.559Z" }, - { url = "https://files.pythonhosted.org/packages/c4/16/3d788388a178a0407aa814b976fe61bfa4af6760d9aac566e59da6e4a8b4/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:edc8446196f04b71d3af76c0bd1fe0a45066ac5bffecca88adb9626ee28c266f", size = 72842, upload-time = "2025-12-06T13:25:18.055Z" }, - { url = "https://files.pythonhosted.org/packages/a6/63/c15b1f8bd47ea48a5a2d52a4ec61f037062932ea6434ab916107b58e861e/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e99f6fa6509c037794da57f906ade271f52276c956d00f748e5b118462021d48", size = 62651, upload-time = "2025-12-06T13:25:19.191Z" }, - { url = "https://files.pythonhosted.org/packages/bd/b8/f544a2e37c778d59208966d4ef19742a0be37c12fc8149ff34483c176616/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d94020ef09f624d841aa9a3a6029df8cf65d60d7a6d5c8687579fa68bd679b65", size = 58295, upload-time = "2025-12-06T13:25:20.822Z" }, - { url = "https://files.pythonhosted.org/packages/03/99/1fae8a3b7ac181e36f6e7864a62d42d5b1f4fa7edf408c6711e28fba6b4d/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f64ce70d89942a23602dee910dec9b48e5edf94351e1b378186b74fcc00d7f66", size = 60960, upload-time = "2025-12-06T13:25:22.099Z" }, - { url = "https://files.pythonhosted.org/packages/9d/9e/cd4c727742345ad8384569a4466f1a1428f4e5cc94d9c2ab2f53d30be3fe/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8ea99f56e45c469818b9781903be86ba4153769f007ba0655fa3b46dc332803d", size = 74863, upload-time = "2025-12-06T13:25:23.442Z" }, - { url = "https://files.pythonhosted.org/packages/28/86/a236ecfc5b494e1e922da149689f690abc84248c7c1358f5605b8c9fdd60/pybase64-1.4.3-cp314-cp314t-win32.whl", hash = "sha256:343b1901103cc72362fd1f842524e3bb24978e31aea7ff11e033af7f373f66ab", size = 34513, upload-time = "2025-12-06T13:25:24.592Z" }, - { url = "https://files.pythonhosted.org/packages/56/ce/ca8675f8d1352e245eb012bfc75429ee9cf1f21c3256b98d9a329d44bf0f/pybase64-1.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:57aff6f7f9dea6705afac9d706432049642de5b01080d3718acc23af87c5af76", size = 36702, upload-time = "2025-12-06T13:25:25.72Z" }, - { url = "https://files.pythonhosted.org/packages/3b/30/4a675864877397179b09b720ee5fcb1cf772cf7bebc831989aff0a5f79c1/pybase64-1.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:e906aa08d4331e799400829e0f5e4177e76a3281e8a4bc82ba114c6b30e405c9", size = 31904, upload-time = "2025-12-06T13:25:26.826Z" }, - { url = "https://files.pythonhosted.org/packages/b2/7c/545fd4935a0e1ddd7147f557bf8157c73eecec9cffd523382fa7af2557de/pybase64-1.4.3-graalpy311-graalpy242_311_native-macosx_10_9_x86_64.whl", hash = "sha256:d27c1dfdb0c59a5e758e7a98bd78eaca5983c22f4a811a36f4f980d245df4611", size = 38393, upload-time = "2025-12-06T13:26:19.535Z" }, - { url = "https://files.pythonhosted.org/packages/c3/ca/ae7a96be9ddc96030d4e9dffc43635d4e136b12058b387fd47eb8301b60f/pybase64-1.4.3-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0f1a0c51d6f159511e3431b73c25db31095ee36c394e26a4349e067c62f434e5", size = 32109, upload-time = "2025-12-06T13:26:20.72Z" }, - { url = "https://files.pythonhosted.org/packages/bf/44/d4b7adc7bf4fd5b52d8d099121760c450a52c390223806b873f0b6a2d551/pybase64-1.4.3-graalpy311-graalpy242_311_native-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a492518f3078a4e3faaef310697d21df9c6bc71908cebc8c2f6fbfa16d7d6b1f", size = 43227, upload-time = "2025-12-06T13:26:21.845Z" }, - { url = "https://files.pythonhosted.org/packages/08/86/2ba2d8734ef7939debeb52cf9952e457ba7aa226cae5c0e6dd631f9b851f/pybase64-1.4.3-graalpy311-graalpy242_311_native-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae1a0f47784fd16df90d8acc32011c8d5fcdd9ab392c9ec49543e5f6a9c43a4", size = 35804, upload-time = "2025-12-06T13:26:23.149Z" }, - { url = "https://files.pythonhosted.org/packages/4f/5b/19c725dc3aaa6281f2ce3ea4c1628d154a40dd99657d1381995f8096768b/pybase64-1.4.3-graalpy311-graalpy242_311_native-win_amd64.whl", hash = "sha256:03cea70676ffbd39a1ab7930a2d24c625b416cacc9d401599b1d29415a43ab6a", size = 35880, upload-time = "2025-12-06T13:26:24.663Z" }, - { url = "https://files.pythonhosted.org/packages/17/45/92322aec1b6979e789b5710f73c59f2172bc37c8ce835305434796824b7b/pybase64-1.4.3-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:2baaa092f3475f3a9c87ac5198023918ea8b6c125f4c930752ab2cbe3cd1d520", size = 38746, upload-time = "2025-12-06T13:26:25.869Z" }, - { url = "https://files.pythonhosted.org/packages/11/94/f1a07402870388fdfc2ecec0c718111189732f7d0f2d7fe1386e19e8fad0/pybase64-1.4.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:cde13c0764b1af07a631729f26df019070dad759981d6975527b7e8ecb465b6c", size = 32573, upload-time = "2025-12-06T13:26:27.792Z" }, - { url = "https://files.pythonhosted.org/packages/fa/8f/43c3bb11ca9bacf81cb0b7a71500bb65b2eda6d5fe07433c09b543de97f3/pybase64-1.4.3-graalpy312-graalpy250_312_native-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5c29a582b0ea3936d02bd6fe9bf674ab6059e6e45ab71c78404ab2c913224414", size = 43461, upload-time = "2025-12-06T13:26:28.906Z" }, - { url = "https://files.pythonhosted.org/packages/2d/4c/2a5258329200be57497d3972b5308558c6de42e3749c6cc2aa1cbe34b25a/pybase64-1.4.3-graalpy312-graalpy250_312_native-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6b664758c804fa919b4f1257aa8cf68e95db76fc331de5f70bfc3a34655afe1", size = 36058, upload-time = "2025-12-06T13:26:30.092Z" }, - { url = "https://files.pythonhosted.org/packages/ea/6d/41faa414cde66ec023b0ca8402a8f11cb61731c3dc27c082909cbbd1f929/pybase64-1.4.3-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:f7537fa22ae56a0bf51e4b0ffc075926ad91c618e1416330939f7ef366b58e3b", size = 36231, upload-time = "2025-12-06T13:26:31.656Z" }, - { url = "https://files.pythonhosted.org/packages/2a/cf/6e712491bd665ea8633efb0b484121893ea838d8e830e06f39f2aae37e58/pybase64-1.4.3-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:94cf50c36bb2f8618982ee5a978c4beed9db97d35944fa96e8586dd953c7994a", size = 38007, upload-time = "2025-12-06T13:26:32.804Z" }, - { url = "https://files.pythonhosted.org/packages/38/c0/9272cae1c49176337dcdbd97511e2843faae1aaf5a5fb48569093c6cd4ce/pybase64-1.4.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:01bc3ff5ca1341685c6d2d945b035f442f7b9c3b068a5c6ee8408a41fda5754e", size = 31538, upload-time = "2025-12-06T13:26:34.001Z" }, - { url = "https://files.pythonhosted.org/packages/20/f2/17546f97befe429c73f622bbd869ceebb518c40fdb0dec4c4f98312e80a5/pybase64-1.4.3-pp310-pypy310_pp73-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:03d0aa3761a99034960496280c02aa063f856a3cc9b33771bc4eab0e4e72b5c2", size = 40682, upload-time = "2025-12-06T13:26:35.168Z" }, - { url = "https://files.pythonhosted.org/packages/92/a0/464b36d5dfb61f3da17858afaeaa876a9342d58e9f17803ce7f28b5de9e8/pybase64-1.4.3-pp310-pypy310_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7ca5b1ce768520acd6440280cdab35235b27ad2faacfcec064bc9c3377066ef1", size = 41306, upload-time = "2025-12-06T13:26:36.351Z" }, - { url = "https://files.pythonhosted.org/packages/07/c9/a748dfc0969a8d960ecf1e82c8a2a16046ffec22f8e7ece582aa3b1c6cf9/pybase64-1.4.3-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3caa1e2ddad1c50553ffaaa1c86b74b3f9fbd505bea9970326ab88fc68c4c184", size = 35452, upload-time = "2025-12-06T13:26:37.772Z" }, - { url = "https://files.pythonhosted.org/packages/95/b7/4d37bd3577d1aa6c732dc099087fe027c48873e223de3784b095e5653f8b/pybase64-1.4.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:bd47076f736b27a8b0f9b30d93b6bb4f5af01b0dc8971f883ed3b75934f39a99", size = 36125, upload-time = "2025-12-06T13:26:39.78Z" }, - { url = "https://files.pythonhosted.org/packages/b2/76/160dded493c00d3376d4ad0f38a2119c5345de4a6693419ad39c3565959b/pybase64-1.4.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:277de6e03cc9090fb359365c686a2a3036d23aee6cd20d45d22b8c89d1247f17", size = 37939, upload-time = "2025-12-06T13:26:41.014Z" }, - { url = "https://files.pythonhosted.org/packages/b7/b8/a0f10be8d648d6f8f26e560d6e6955efa7df0ff1e009155717454d76f601/pybase64-1.4.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ab1dd8b1ed2d1d750260ed58ab40defaa5ba83f76a30e18b9ebd5646f6247ae5", size = 31466, upload-time = "2025-12-06T13:26:42.539Z" }, - { url = "https://files.pythonhosted.org/packages/d3/22/832a2f9e76cdf39b52e01e40d8feeb6a04cf105494f2c3e3126d0149717f/pybase64-1.4.3-pp311-pypy311_pp73-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:bd4d2293de9fd212e294c136cec85892460b17d24e8c18a6ba18750928037750", size = 40681, upload-time = "2025-12-06T13:26:43.782Z" }, - { url = "https://files.pythonhosted.org/packages/12/d7/6610f34a8972415fab3bb4704c174a1cc477bffbc3c36e526428d0f3957d/pybase64-1.4.3-pp311-pypy311_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af6d0d3a691911cc4c9a625f3ddcd3af720738c21be3d5c72de05629139d393", size = 41294, upload-time = "2025-12-06T13:26:44.936Z" }, - { url = "https://files.pythonhosted.org/packages/64/25/ed24400948a6c974ab1374a233cb7e8af0a5373cea0dd8a944627d17c34a/pybase64-1.4.3-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5cfc8c49a28322d82242088378f8542ce97459866ba73150b062a7073e82629d", size = 35447, upload-time = "2025-12-06T13:26:46.098Z" }, - { url = "https://files.pythonhosted.org/packages/ee/2b/e18ee7c5ee508a82897f021c1981533eca2940b5f072fc6ed0906c03a7a7/pybase64-1.4.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:debf737e09b8bf832ba86f5ecc3d3dbd0e3021d6cd86ba4abe962d6a5a77adb3", size = 36134, upload-time = "2025-12-06T13:26:47.35Z" }, -] - [[package]] name = "pycparser" version = "3.0" @@ -4553,21 +2728,6 @@ crypto = [ { name = "cryptography" }, ] -[[package]] -name = "pymupdf" -version = "1.26.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/48/d6/09b28f027b510838559f7748807192149c419b30cb90e6d5f0cf916dc9dc/pymupdf-1.26.7.tar.gz", hash = "sha256:71add8bdc8eb1aaa207c69a13400693f06ad9b927bea976f5d5ab9df0bb489c3", size = 84327033, upload-time = "2025-12-11T21:48:50.694Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/35/cd74cea1787b2247702ef8522186bdef32e9cb30a099e6bb864627ef6045/pymupdf-1.26.7-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:07085718dfdae5ab83b05eb5eb397f863bcc538fe05135318a01ea353e7a1353", size = 23179369, upload-time = "2025-12-11T21:47:21.587Z" }, - { url = "https://files.pythonhosted.org/packages/72/74/448b6172927c829c6a3fba80078d7b0a016ebbe2c9ee528821f5ea21677a/pymupdf-1.26.7-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:31aa9c8377ea1eea02934b92f4dcf79fb2abba0bf41f8a46d64c3e31546a3c02", size = 22470101, upload-time = "2025-12-11T21:47:37.105Z" }, - { url = "https://files.pythonhosted.org/packages/65/e7/47af26f3ac76be7ac3dd4d6cc7ee105948a8355d774e5ca39857bf91c11c/pymupdf-1.26.7-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e419b609996434a14a80fa060adec72c434a1cca6a511ec54db9841bc5d51b3c", size = 23502486, upload-time = "2025-12-12T09:51:25.824Z" }, - { url = "https://files.pythonhosted.org/packages/2a/6b/3de1714d734ff949be1e90a22375d0598d3540b22ae73eb85c2d7d1f36a9/pymupdf-1.26.7-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:69dfc78f206a96e5b3ac22741263ebab945fdf51f0dbe7c5757c3511b23d9d72", size = 24115727, upload-time = "2025-12-11T21:47:51.274Z" }, - { url = "https://files.pythonhosted.org/packages/62/9b/f86224847949577a523be2207315ae0fd3155b5d909cd66c274d095349a3/pymupdf-1.26.7-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1d5106f46e1ca0d64d46bd51892372a4f82076bdc14a9678d33d630702abca36", size = 24324386, upload-time = "2025-12-12T14:58:45.483Z" }, - { url = "https://files.pythonhosted.org/packages/85/8e/a117d39092ca645fde8b903f4a941d9aa75b370a67b4f1f435f56393dc5a/pymupdf-1.26.7-cp310-abi3-win32.whl", hash = "sha256:7c9645b6f5452629c747690190350213d3e5bbdb6b2eca227d82702b327f6eee", size = 17203888, upload-time = "2025-12-12T13:59:57.613Z" }, - { url = "https://files.pythonhosted.org/packages/dd/c3/d0047678146c294469c33bae167c8ace337deafb736b0bf97b9bc481aa65/pymupdf-1.26.7-cp310-abi3-win_amd64.whl", hash = "sha256:425b1befe40d41b72eb0fe211711c7ae334db5eb60307e9dd09066ed060cceba", size = 18405952, upload-time = "2025-12-11T21:48:02.947Z" }, -] - [[package]] name = "pynacl" version = "1.6.2" @@ -4624,56 +2784,6 @@ 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 = "pypdfium2" -version = "5.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/98/6b44bf82ddb3c7a3e0249203772aad8981b4491d6227f182685f310faeff/pypdfium2-5.9.0.tar.gz", hash = "sha256:db1274bd27844db6fda17ef1dbcd0026c47d357437058d838e98060c0da9e92e", size = 272455, upload-time = "2026-06-01T15:43:38.08Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/d9/59630cb40e5f37e7712e6ea65e9cac633f4195e8b737bb3a46054aa63340/pypdfium2-5.9.0-py3-none-android_23_arm64_v8a.whl", hash = "sha256:91914837c4a4285b3e0724a84eca8079363db7475acbcab405933d1807785664", size = 3407817, upload-time = "2026-06-01T15:42:58.426Z" }, - { url = "https://files.pythonhosted.org/packages/0f/3d/e205708835a3730d5242652b6577ac06ad4721e6fcef77cc7c9d3541c686/pypdfium2-5.9.0-py3-none-android_23_armeabi_v7a.whl", hash = "sha256:90610d352f050b065b703f3a46602a852fce7dd8787300c8c7a472485b644d8f", size = 2862706, upload-time = "2026-06-01T15:43:00.581Z" }, - { url = "https://files.pythonhosted.org/packages/01/47/e843fb895a891438b3f8c6d834fdc9c19183cd60980fc9325429d5c01505/pypdfium2-5.9.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6c4fbe3a7190b329c526358fb2855d797f7b74b5ecfc61d19657ef20bcebc108", size = 3489945, upload-time = "2026-06-01T15:43:02.542Z" }, - { url = "https://files.pythonhosted.org/packages/35/bd/f5e6afd556f97fcaa2bec4cb04669664c166028fc2a059bd65447c852b43/pypdfium2-5.9.0-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:e93f0cf440169a3e445e6fbd06c803877e7418f3e13254287875cb67f208bb5a", size = 3674186, upload-time = "2026-06-01T15:43:04.496Z" }, - { url = "https://files.pythonhosted.org/packages/6d/4d/5286812216a292d51dfba8e7bff276da198f126508f8c2afa3630bf701dc/pypdfium2-5.9.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d902e03dff5efd51d93cd23d3e55bde53802fa6207bcd0e455239518859a069", size = 3669571, upload-time = "2026-06-01T15:43:06.571Z" }, - { url = "https://files.pythonhosted.org/packages/ac/c8/822db2c89baa13e6cee321d587fcd42df463a1fc2f7520b3f6814768bc71/pypdfium2-5.9.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cf38d7ad3575947b82384869f2ab69ba345eb21d83118d25db3e83f967b0421", size = 3400412, upload-time = "2026-06-01T15:43:08.35Z" }, - { url = "https://files.pythonhosted.org/packages/1a/dd/7d09d8cdc28383df13f739a97ac4f1215a704a97a29506dee2bf89d8a350/pypdfium2-5.9.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:77f7479a28b43aa658735e3ce79cfd1fccd5d42db035c21bb4c26e8bd7e280e5", size = 3803326, upload-time = "2026-06-01T15:43:10.054Z" }, - { url = "https://files.pythonhosted.org/packages/99/58/3f4e04ffe1ae62b437de07a96da672091cef62b619d0dc78207c1af442e6/pypdfium2-5.9.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:07e6ba170d577eabf60dbba701d051c64318dd029d38ca5907d83ae1a66fe779", size = 4216890, upload-time = "2026-06-01T15:43:11.701Z" }, - { url = "https://files.pythonhosted.org/packages/1d/f6/2dde4656750c4a6da99e1f070ca09d2b5a9d68186b42e711a1a3e5b1cb32/pypdfium2-5.9.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ce3a3dd23ec0adaa079d8be54565ba2aa2f6060e76a4989cd42dabc163d74ee", size = 3728830, upload-time = "2026-06-01T15:43:13.329Z" }, - { url = "https://files.pythonhosted.org/packages/d0/ca/f2ff8b9200c7dfc5aee85126edc856eb93c7056085da2454a75ef1e4dbc4/pypdfium2-5.9.0-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae177938f5cf95a275db25a4f8553e2ebd954ecda2f9bc84848ba4b027ce438f", size = 4063322, upload-time = "2026-06-01T15:43:15.158Z" }, - { url = "https://files.pythonhosted.org/packages/64/88/0b587de03c873c28adc59f6ac959de4032d3f3bc946094523b14a192d9c3/pypdfium2-5.9.0-py3-none-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffe49edde2ac86f28ca7e58f565255a442f38a7508fff31b79a55f508f25a31e", size = 4039738, upload-time = "2026-06-01T15:43:16.975Z" }, - { url = "https://files.pythonhosted.org/packages/83/4c/fa627f00a954e66465e929077cf43bd012595091fff82758d989486e7bdc/pypdfium2-5.9.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b7b760bc2957ecf73c274af6ed8b168a2dcb328ac0a0f7ed6123cd92f6e7c9c9", size = 4997259, upload-time = "2026-06-01T15:43:18.915Z" }, - { url = "https://files.pythonhosted.org/packages/32/f0/1736d80c5d12d931f74ca6b4213b006ee016ec33c6325fad870234cc240c/pypdfium2-5.9.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7cdc8e5d2f8d82add1e4f70a4fbe5f3b33c17f301ebde38c669fd7f78a7d032c", size = 4537061, upload-time = "2026-06-01T15:43:20.879Z" }, - { url = "https://files.pythonhosted.org/packages/01/00/aa8890dfd385b2e7365034231987029cff15cc7eb4f06e8380da5608738a/pypdfium2-5.9.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:38a058dbd4929acaf0ab9171179eb86c24d8c6655a6836006796105a9f200890", size = 5232786, upload-time = "2026-06-01T15:43:23.73Z" }, - { url = "https://files.pythonhosted.org/packages/65/12/8f45ea698781a0bed96ac4fbde440060790863273943461f0f160a993d52/pypdfium2-5.9.0-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:1894511a0e862e7ec5679f3a6dc43ac72c4ef92c7ca438357203913e8634a643", size = 5170121, upload-time = "2026-06-01T15:43:25.858Z" }, - { url = "https://files.pythonhosted.org/packages/25/bd/9bb6ba375796e1de1d6c1af8d8303dd1781190346871c81a94d4e09eddfd/pypdfium2-5.9.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:040f5513b808db705d4878f57e2bf0b9dc6e6a0ad8d765c36cf62febf3933b28", size = 4663540, upload-time = "2026-06-01T15:43:27.677Z" }, - { url = "https://files.pythonhosted.org/packages/d2/4a/fd103bac197f22038bf70be1f7507ced7519f1214ea0dae137f37803ab8a/pypdfium2-5.9.0-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:f4991ae39bcea757552579bba4aebfaedb71c96dd35c2292f957b8ac9132f1ff", size = 5090619, upload-time = "2026-06-01T15:43:29.522Z" }, - { url = "https://files.pythonhosted.org/packages/22/89/9531fa1e6e004fe522cdca0cd945cd6a9d7338e7125e6b0734d632d31fa6/pypdfium2-5.9.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:25ff1a5abd08ff9e87f62e5dac114ea95647c257fbbdbe029be8db71a6d7650b", size = 5050806, upload-time = "2026-06-01T15:43:31.322Z" }, - { url = "https://files.pythonhosted.org/packages/fc/d0/e53c68555ff128b2470e4a468762b320d9c6ae2c914decea3487d923982f/pypdfium2-5.9.0-py3-none-win32.whl", hash = "sha256:b0057dc8c2033584dc3e61afb5f23a135dab52b081695b435e27f9b7b074c605", size = 3670966, upload-time = "2026-06-01T15:43:32.991Z" }, - { url = "https://files.pythonhosted.org/packages/da/0c/22e5fc035ad1594b44f265bc0a59ae34d377bc2ea74a92793e7a674bf96d/pypdfium2-5.9.0-py3-none-win_amd64.whl", hash = "sha256:06508c33b9772cf3878e48364c6e14c70cefc18a3abd6983ac9f338da9305275", size = 3800959, upload-time = "2026-06-01T15:43:34.536Z" }, - { url = "https://files.pythonhosted.org/packages/11/e3/cf1711add7add22a17f7c7633cd795edc92f17ab7bdf1930493ae0f56680/pypdfium2-5.9.0-py3-none-win_arm64.whl", hash = "sha256:565ddfc98795fd2f6054b544ee9791d7b9032f9cf77a57891b6e501fafd0ef3f", size = 3585718, upload-time = "2026-06-01T15:43:36.521Z" }, -] - -[[package]] -name = "pypika" -version = "0.51.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f8/78/cbaebba88e05e2dcda13ca203131b38d3640219f20ebb49676d26714861b/pypika-0.51.1.tar.gz", hash = "sha256:c30c7c1048fbf056fd3920c5a2b88b0c29dd190a9b2bee971fd17e4abe4d0ebe", size = 80919, upload-time = "2026-02-04T11:27:48.304Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/57/83/c77dfeed04022e8930b08eedca2b6e5efed256ab3321396fde90066efb65/pypika-0.51.1-py2.py3-none-any.whl", hash = "sha256:77985b4d7ce71b9905255bf12468cf598349e98837c037541cfc240e528aec46", size = 60585, upload-time = "2026-02-04T11:27:46.251Z" }, -] - -[[package]] -name = "pyproject-hooks" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, -] - [[package]] name = "pytest" version = "9.0.3" @@ -4769,19 +2879,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] -[[package]] -name = "python-docx" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "lxml" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" }, -] - [[package]] name = "python-dotenv" version = "1.2.2" @@ -4827,15 +2924,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] -[[package]] -name = "pytube" -version = "15.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/e7/16fec46c8d255c4bbc4b185d89c91dc92cdb802836570d8004d0db169c91/pytube-15.0.0.tar.gz", hash = "sha256:076052efe76f390dfa24b1194ff821d4e86c17d41cb5562f3a276a8bcbfc9d1d", size = 67229, upload-time = "2023-05-07T19:39:01.903Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/64/bcf8632ed2b7a36bbf84a0544885ffa1d0b4bcf25cc0903dba66ec5fdad9/pytube-15.0.0-py3-none-any.whl", hash = "sha256:07b9904749e213485780d7eb606e5e5b8e4341aa4dccf699160876da00e12d78", size = 57594, upload-time = "2023-05-07T19:38:59.191Z" }, -] - [[package]] name = "pywin32" version = "312" @@ -5076,19 +3164,6 @@ 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 = "requests-oauthlib" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "oauthlib" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, -] - [[package]] name = "requests-toolbelt" version = "1.0.0" @@ -5428,304 +3503,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2b/58/a58fc997655386daa2e25784e30c288aa3e3819e401f77029ee4899fb55a/s3transfer-0.18.0-py3-none-any.whl", hash = "sha256:239c13b09e65ad0346e1be7348b8a202dcad44ac7ea7c6eb858fc881dce739b6", size = 88572, upload-time = "2026-05-28T19:39:07.999Z" }, ] -[[package]] -name = "safetensors" -version = "0.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/45/06/f955dbbb1859e3bd23c8ac6141af5106e7ad5fedec4a3a6e3d60f94b7001/safetensors-0.8.0.tar.gz", hash = "sha256:fabaf3e0f18a6618d9b36560682562157f77c2b71fcffc7b432be2baed9d753d", size = 325846, upload-time = "2026-06-09T07:52:25.563Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/39/a0/f718cda65b05407d228f97602cf60dca269c979867aa5beb25410de26cd3/safetensors-0.8.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c554f85858e05226d3c2828e32395e677434685d6d94594a41643361c5e837f0", size = 473568, upload-time = "2026-06-09T07:52:18.829Z" }, - { url = "https://files.pythonhosted.org/packages/f5/b1/fa7c600e7dceae12e9606c7578cbc9ff1e1ed55844883ee5c92205e86226/safetensors-0.8.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c80201d22cbf405b80647a60ada77bba06c8fba2da2743ba1e89cdcc39a81f25", size = 484562, upload-time = "2026-06-09T07:52:17.518Z" }, - { url = "https://files.pythonhosted.org/packages/09/7d/65a7de0af421317bb36a067241e4235fff194eed60b961ed6d3f59a3fc60/safetensors-0.8.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a46e5ff292c356d6991e60942ba7f79817682d3a2cef0702136448cb9c4d235", size = 502844, upload-time = "2026-06-09T07:52:07.624Z" }, - { url = "https://files.pythonhosted.org/packages/91/4f/3175c9d75634e0e0dda0082794193521035edd7c70a6f212bf33ca06ddf4/safetensors-0.8.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4124502b78f03534117c848f87a39b8f31e577b15eff423bf8bfb95f2a8c30d0", size = 511823, upload-time = "2026-06-09T07:52:09.565Z" }, - { url = "https://files.pythonhosted.org/packages/20/87/846c289e7aa2299eff406335717cf43ce8777194ece8aad75772e0411615/safetensors-0.8.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7bc0a787ba8a35be368ee3574edfa2b1ad389eebd0a72e482ae275490e3f6c98", size = 633461, upload-time = "2026-06-09T07:52:11.128Z" }, - { url = "https://files.pythonhosted.org/packages/76/22/8d64d9df2c45d5ded401df889d0ad90882804ca172d79ec4f0df8f727fe0/safetensors-0.8.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:040070828e36dc8e122178bbbd5830ff9e97920affb84cbe0f46442497bed358", size = 545148, upload-time = "2026-06-09T07:52:13.603Z" }, - { url = "https://files.pythonhosted.org/packages/28/50/f203ff3a3ddfe19308efc83c5a3a29ed02bf786732ec35e68bf9162f3365/safetensors-0.8.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd6f3f93c9a0a7cc2788ee63fb763353d4bd2e89b0751bc78fcf7dda00bea774", size = 516040, upload-time = "2026-06-09T07:52:16.29Z" }, - { url = "https://files.pythonhosted.org/packages/46/fb/cdaed17ceb2948784fd9c36b6fd3e951b608547cea81a48e8ee6f8cfdfcb/safetensors-0.8.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:fcdd41ec4628fee5799f807c73c353629130fbd942aa23d83c623dd6c9d52d78", size = 513832, upload-time = "2026-06-09T07:52:12.37Z" }, - { url = "https://files.pythonhosted.org/packages/0d/49/1e15de264dcc3b77943d2d0c56a95809956883b1c2d6d585c792523f180b/safetensors-0.8.0-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8e9f537aa183a38ace122d27303dcd986b26bd2a7591f9181d7f0c396f4677ca", size = 559930, upload-time = "2026-06-09T07:52:14.743Z" }, - { url = "https://files.pythonhosted.org/packages/2a/43/bf38443278eab4b1be1fce2931e2b012ad9cb7df52ada751d0aab8f7659a/safetensors-0.8.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:87eec7ffed2b809f05a398a8becb7d013f19f7837cd15d9748580d6cf30dbaf4", size = 678670, upload-time = "2026-06-09T07:52:20.032Z" }, - { url = "https://files.pythonhosted.org/packages/72/e3/68cd3fa5b48488e84add63e04cb12f3bc28ae4638c06d4508c6e88823d0e/safetensors-0.8.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:4a95ae2b05d7726d751da4ebf626a2ca782b706e101bd894c95bc2450b1cffcc", size = 786679, upload-time = "2026-06-09T07:52:21.322Z" }, - { url = "https://files.pythonhosted.org/packages/29/4b/1c19c509d56e01f4fbb3d0a2e597450f6cc04d1d56cf52defb0a62dfd715/safetensors-0.8.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae091f16662658bdc019a4ff6cb4c085bb7d725eb5978b183ffd265863b6d2d", size = 765683, upload-time = "2026-06-09T07:52:22.594Z" }, - { url = "https://files.pythonhosted.org/packages/27/43/41c1621732edd934d868a00d1b891584c892a7b62a9aab82ea5a0a5623ee/safetensors-0.8.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8e080062fcde23be189565e1c3305d16751a218ecf9412c8601e64204eb6f846", size = 722361, upload-time = "2026-06-09T07:52:23.924Z" }, - { url = "https://files.pythonhosted.org/packages/8e/3f/73ccf82579412b4a71c4ca673f10b5f1f888d7cf5af7fe24f27d30307be4/safetensors-0.8.0-cp310-abi3-win32.whl", hash = "sha256:2ddf52eac562eda224f99acfa7889d02968c1fd59a5b011ae7d8137c37e9c02d", size = 342401, upload-time = "2026-06-09T07:52:28.895Z" }, - { url = "https://files.pythonhosted.org/packages/1b/6d/3fba214c1e5e0f69991677ec3bc17023f0421776975e1de0c682dca475e2/safetensors-0.8.0-cp310-abi3-win_amd64.whl", hash = "sha256:096ec1a98435df7beb08853bb5aa9081a84f23d0adc67ed1a0a10550f608373f", size = 355540, upload-time = "2026-06-09T07:52:27.832Z" }, - { url = "https://files.pythonhosted.org/packages/8d/fc/7eedc3510d97878876e32774eebbeb61c43f148a96e915c84229a3e967aa/safetensors-0.8.0-cp310-abi3-win_arm64.whl", hash = "sha256:f7838e5135a406ad3e02efdcb8cf2e5397d368b0154537c4fec682dbc544d452", size = 340500, upload-time = "2026-06-09T07:52:26.745Z" }, -] - -[[package]] -name = "scikit-learn" -version = "1.7.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -dependencies = [ - { name = "joblib", marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "threadpoolctl", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/98/c2/a7855e41c9d285dfe86dc50b250978105dce513d6e459ea66a6aeb0e1e0c/scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda", size = 7193136, upload-time = "2025-09-09T08:21:29.075Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/3e/daed796fd69cce768b8788401cc464ea90b306fb196ae1ffed0b98182859/scikit_learn-1.7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b33579c10a3081d076ab403df4a4190da4f4432d443521674637677dc91e61f", size = 9336221, upload-time = "2025-09-09T08:20:19.328Z" }, - { url = "https://files.pythonhosted.org/packages/1c/ce/af9d99533b24c55ff4e18d9b7b4d9919bbc6cd8f22fe7a7be01519a347d5/scikit_learn-1.7.2-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:36749fb62b3d961b1ce4fedf08fa57a1986cd409eff2d783bca5d4b9b5fce51c", size = 8653834, upload-time = "2025-09-09T08:20:22.073Z" }, - { url = "https://files.pythonhosted.org/packages/58/0e/8c2a03d518fb6bd0b6b0d4b114c63d5f1db01ff0f9925d8eb10960d01c01/scikit_learn-1.7.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7a58814265dfc52b3295b1900cfb5701589d30a8bb026c7540f1e9d3499d5ec8", size = 9660938, upload-time = "2025-09-09T08:20:24.327Z" }, - { url = "https://files.pythonhosted.org/packages/2b/75/4311605069b5d220e7cf5adabb38535bd96f0079313cdbb04b291479b22a/scikit_learn-1.7.2-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a847fea807e278f821a0406ca01e387f97653e284ecbd9750e3ee7c90347f18", size = 9477818, upload-time = "2025-09-09T08:20:26.845Z" }, - { url = "https://files.pythonhosted.org/packages/7f/9b/87961813c34adbca21a6b3f6b2bea344c43b30217a6d24cc437c6147f3e8/scikit_learn-1.7.2-cp310-cp310-win_amd64.whl", hash = "sha256:ca250e6836d10e6f402436d6463d6c0e4d8e0234cfb6a9a47835bd392b852ce5", size = 8886969, upload-time = "2025-09-09T08:20:29.329Z" }, - { url = "https://files.pythonhosted.org/packages/43/83/564e141eef908a5863a54da8ca342a137f45a0bfb71d1d79704c9894c9d1/scikit_learn-1.7.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7509693451651cd7361d30ce4e86a1347493554f172b1c72a39300fa2aea79e", size = 9331967, upload-time = "2025-09-09T08:20:32.421Z" }, - { url = "https://files.pythonhosted.org/packages/18/d6/ba863a4171ac9d7314c4d3fc251f015704a2caeee41ced89f321c049ed83/scikit_learn-1.7.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:0486c8f827c2e7b64837c731c8feff72c0bd2b998067a8a9cbc10643c31f0fe1", size = 8648645, upload-time = "2025-09-09T08:20:34.436Z" }, - { url = "https://files.pythonhosted.org/packages/ef/0e/97dbca66347b8cf0ea8b529e6bb9367e337ba2e8be0ef5c1a545232abfde/scikit_learn-1.7.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89877e19a80c7b11a2891a27c21c4894fb18e2c2e077815bcade10d34287b20d", size = 9715424, upload-time = "2025-09-09T08:20:36.776Z" }, - { url = "https://files.pythonhosted.org/packages/f7/32/1f3b22e3207e1d2c883a7e09abb956362e7d1bd2f14458c7de258a26ac15/scikit_learn-1.7.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8da8bf89d4d79aaec192d2bda62f9b56ae4e5b4ef93b6a56b5de4977e375c1f1", size = 9509234, upload-time = "2025-09-09T08:20:38.957Z" }, - { url = "https://files.pythonhosted.org/packages/9f/71/34ddbd21f1da67c7a768146968b4d0220ee6831e4bcbad3e03dd3eae88b6/scikit_learn-1.7.2-cp311-cp311-win_amd64.whl", hash = "sha256:9b7ed8d58725030568523e937c43e56bc01cadb478fc43c042a9aca1dacb3ba1", size = 8894244, upload-time = "2025-09-09T08:20:41.166Z" }, - { url = "https://files.pythonhosted.org/packages/a7/aa/3996e2196075689afb9fce0410ebdb4a09099d7964d061d7213700204409/scikit_learn-1.7.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8d91a97fa2b706943822398ab943cde71858a50245e31bc71dba62aab1d60a96", size = 9259818, upload-time = "2025-09-09T08:20:43.19Z" }, - { url = "https://files.pythonhosted.org/packages/43/5d/779320063e88af9c4a7c2cf463ff11c21ac9c8bd730c4a294b0000b666c9/scikit_learn-1.7.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:acbc0f5fd2edd3432a22c69bed78e837c70cf896cd7993d71d51ba6708507476", size = 8636997, upload-time = "2025-09-09T08:20:45.468Z" }, - { url = "https://files.pythonhosted.org/packages/5c/d0/0c577d9325b05594fdd33aa970bf53fb673f051a45496842caee13cfd7fe/scikit_learn-1.7.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e5bf3d930aee75a65478df91ac1225ff89cd28e9ac7bd1196853a9229b6adb0b", size = 9478381, upload-time = "2025-09-09T08:20:47.982Z" }, - { url = "https://files.pythonhosted.org/packages/82/70/8bf44b933837ba8494ca0fc9a9ab60f1c13b062ad0197f60a56e2fc4c43e/scikit_learn-1.7.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4d6e9deed1a47aca9fe2f267ab8e8fe82ee20b4526b2c0cd9e135cea10feb44", size = 9300296, upload-time = "2025-09-09T08:20:50.366Z" }, - { url = "https://files.pythonhosted.org/packages/c6/99/ed35197a158f1fdc2fe7c3680e9c70d0128f662e1fee4ed495f4b5e13db0/scikit_learn-1.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:6088aa475f0785e01bcf8529f55280a3d7d298679f50c0bb70a2364a82d0b290", size = 8731256, upload-time = "2025-09-09T08:20:52.627Z" }, - { url = "https://files.pythonhosted.org/packages/ae/93/a3038cb0293037fd335f77f31fe053b89c72f17b1c8908c576c29d953e84/scikit_learn-1.7.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0b7dacaa05e5d76759fb071558a8b5130f4845166d88654a0f9bdf3eb57851b7", size = 9212382, upload-time = "2025-09-09T08:20:54.731Z" }, - { url = "https://files.pythonhosted.org/packages/40/dd/9a88879b0c1104259136146e4742026b52df8540c39fec21a6383f8292c7/scikit_learn-1.7.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:abebbd61ad9e1deed54cca45caea8ad5f79e1b93173dece40bb8e0c658dbe6fe", size = 8592042, upload-time = "2025-09-09T08:20:57.313Z" }, - { url = "https://files.pythonhosted.org/packages/46/af/c5e286471b7d10871b811b72ae794ac5fe2989c0a2df07f0ec723030f5f5/scikit_learn-1.7.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:502c18e39849c0ea1a5d681af1dbcf15f6cce601aebb657aabbfe84133c1907f", size = 9434180, upload-time = "2025-09-09T08:20:59.671Z" }, - { url = "https://files.pythonhosted.org/packages/f1/fd/df59faa53312d585023b2da27e866524ffb8faf87a68516c23896c718320/scikit_learn-1.7.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a4c328a71785382fe3fe676a9ecf2c86189249beff90bf85e22bdb7efaf9ae0", size = 9283660, upload-time = "2025-09-09T08:21:01.71Z" }, - { url = "https://files.pythonhosted.org/packages/a7/c7/03000262759d7b6f38c836ff9d512f438a70d8a8ddae68ee80de72dcfb63/scikit_learn-1.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:63a9afd6f7b229aad94618c01c252ce9e6fa97918c5ca19c9a17a087d819440c", size = 8702057, upload-time = "2025-09-09T08:21:04.234Z" }, - { url = "https://files.pythonhosted.org/packages/55/87/ef5eb1f267084532c8e4aef98a28b6ffe7425acbfd64b5e2f2e066bc29b3/scikit_learn-1.7.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:9acb6c5e867447b4e1390930e3944a005e2cb115922e693c08a323421a6966e8", size = 9558731, upload-time = "2025-09-09T08:21:06.381Z" }, - { url = "https://files.pythonhosted.org/packages/93/f8/6c1e3fc14b10118068d7938878a9f3f4e6d7b74a8ddb1e5bed65159ccda8/scikit_learn-1.7.2-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:2a41e2a0ef45063e654152ec9d8bcfc39f7afce35b08902bfe290c2498a67a6a", size = 9038852, upload-time = "2025-09-09T08:21:08.628Z" }, - { url = "https://files.pythonhosted.org/packages/83/87/066cafc896ee540c34becf95d30375fe5cbe93c3b75a0ee9aa852cd60021/scikit_learn-1.7.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98335fb98509b73385b3ab2bd0639b1f610541d3988ee675c670371d6a87aa7c", size = 9527094, upload-time = "2025-09-09T08:21:11.486Z" }, - { url = "https://files.pythonhosted.org/packages/9c/2b/4903e1ccafa1f6453b1ab78413938c8800633988c838aa0be386cbb33072/scikit_learn-1.7.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:191e5550980d45449126e23ed1d5e9e24b2c68329ee1f691a3987476e115e09c", size = 9367436, upload-time = "2025-09-09T08:21:13.602Z" }, - { url = "https://files.pythonhosted.org/packages/b5/aa/8444be3cfb10451617ff9d177b3c190288f4563e6c50ff02728be67ad094/scikit_learn-1.7.2-cp313-cp313t-win_amd64.whl", hash = "sha256:57dc4deb1d3762c75d685507fbd0bc17160144b2f2ba4ccea5dc285ab0d0e973", size = 9275749, upload-time = "2025-09-09T08:21:15.96Z" }, - { url = "https://files.pythonhosted.org/packages/d9/82/dee5acf66837852e8e68df6d8d3a6cb22d3df997b733b032f513d95205b7/scikit_learn-1.7.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fa8f63940e29c82d1e67a45d5297bdebbcb585f5a5a50c4914cc2e852ab77f33", size = 9208906, upload-time = "2025-09-09T08:21:18.557Z" }, - { url = "https://files.pythonhosted.org/packages/3c/30/9029e54e17b87cb7d50d51a5926429c683d5b4c1732f0507a6c3bed9bf65/scikit_learn-1.7.2-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:f95dc55b7902b91331fa4e5845dd5bde0580c9cd9612b1b2791b7e80c3d32615", size = 8627836, upload-time = "2025-09-09T08:21:20.695Z" }, - { url = "https://files.pythonhosted.org/packages/60/18/4a52c635c71b536879f4b971c2cedf32c35ee78f48367885ed8025d1f7ee/scikit_learn-1.7.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9656e4a53e54578ad10a434dc1f993330568cfee176dff07112b8785fb413106", size = 9426236, upload-time = "2025-09-09T08:21:22.645Z" }, - { url = "https://files.pythonhosted.org/packages/99/7e/290362f6ab582128c53445458a5befd471ed1ea37953d5bcf80604619250/scikit_learn-1.7.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96dc05a854add0e50d3f47a1ef21a10a595016da5b007c7d9cd9d0bffd1fcc61", size = 9312593, upload-time = "2025-09-09T08:21:24.65Z" }, - { url = "https://files.pythonhosted.org/packages/8e/87/24f541b6d62b1794939ae6422f8023703bbf6900378b2b34e0b4384dfefd/scikit_learn-1.7.2-cp314-cp314-win_amd64.whl", hash = "sha256:bb24510ed3f9f61476181e4db51ce801e2ba37541def12dc9333b946fc7a9cf8", size = 8820007, upload-time = "2025-09-09T08:21:26.713Z" }, -] - -[[package]] -name = "scikit-learn" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version == '3.14.*'", - "python_full_version == '3.13.*'", - "python_full_version >= '3.11' and python_full_version < '3.13'", -] -dependencies = [ - { name = "joblib", marker = "python_full_version >= '3.11'" }, - { name = "narwhals", marker = "python_full_version >= '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "threadpoolctl", marker = "python_full_version >= '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/be/e844fd9586e66540a15b71924d17a6cbc1bb749e81ddd0a796bcdba4c055/scikit_learn-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9db6f4d34e68c8899e4cab27fdf8eafe6ed21f2ba52ceb25ea250cd237f8e47b", size = 8789686, upload-time = "2026-06-02T11:53:05.439Z" }, - { url = "https://files.pythonhosted.org/packages/42/e2/ff880f62677a17d035817d543cb0fc8727d01eccbee81c5f7fc733a9d856/scikit_learn-1.9.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f401448645a3e7bc115aa3c094097865155b34bff1cba8101857d9104e99074c", size = 8256782, upload-time = "2026-06-02T11:53:08.904Z" }, - { url = "https://files.pythonhosted.org/packages/25/64/eb40435e1a508ab1b4e284ce43ae80f6a162e5be5e38ed5a6fab467a9ea4/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd3a8ef0c758555a3b23c03adaa858af32f7736785ded50ad5991f59c4ed03fa", size = 8992419, upload-time = "2026-06-02T11:53:11.551Z" }, - { url = "https://files.pythonhosted.org/packages/8d/da/4810a28e473185429e45a57eebcc91fc991b33d889cc0676063e671db03d/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7e254636164090da847715a27f8e5478feb98c40a9e0ee90cbd277de9e5ceb8", size = 9281411, upload-time = "2026-06-02T11:53:15.063Z" }, - { url = "https://files.pythonhosted.org/packages/3b/67/be3d369f40d8178ba3bd86635d132e08cb5329b023e4669d9426d84bc007/scikit_learn-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:5dc1818c77575d149e25fce9ef82dd7b7263ae372f03494158668ad632a69759", size = 8272736, upload-time = "2026-06-02T11:53:18.108Z" }, - { url = "https://files.pythonhosted.org/packages/37/79/a733f02dc2118da7e77a134b34f39f40201a353311b011d20859d2db3556/scikit_learn-1.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:366652351f092b219c248f1e72821e841960a63d8f358f1dcfd54dc1cbdbbc28", size = 7919564, upload-time = "2026-06-02T11:53:21.2Z" }, - { url = "https://files.pythonhosted.org/packages/ac/20/75f915ff375d6249e6550ac740fdbbd66159a068fd3af1400ff62036b07a/scikit_learn-1.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2bd41b0d201bc81575531b96b713d3eb5e5f50fb0b82101ff0f92294fdc236ac", size = 8741122, upload-time = "2026-06-02T11:53:24.08Z" }, - { url = "https://files.pythonhosted.org/packages/cc/d5/2b5148f2279196775e1db2aeb85d14b70ac80e7e32b3b28e7ebeafb0901d/scikit_learn-1.9.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5be45aa4a42a68a533913a6ed736cf309de2226411c79ef8d609a5456f1939b1", size = 8261512, upload-time = "2026-06-02T11:53:27.183Z" }, - { url = "https://files.pythonhosted.org/packages/a0/ee/5adbc77656b71f9456a2f5a7a9fdb4bcf9207a6b962889f1c2f9323afa4e/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e50ed4da51974e86e940690e9a3d82e729b62b5a49f7c9bac534d515d39d86f", size = 8837603, upload-time = "2026-06-02T11:53:30.328Z" }, - { url = "https://files.pythonhosted.org/packages/6c/c2/63fdda36c56437eeb44aaf9493c8bcd62ce230ab1598924fc626ffbfa943/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:056c92bb67ad4c28463c2f2653d9701449201e7e7a9e94e321be0f71c4fef2b8", size = 9132097, upload-time = "2026-06-02T11:53:33.456Z" }, - { url = "https://files.pythonhosted.org/packages/83/a4/c8e67227c680e2259c8864ae72ff48b06e16a6f51253a22167aa02a8aa4e/scikit_learn-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:4306775fad04cc4b472a1b15af1ae9cede1540fbfcc17fbce3767cd8dc7ae283", size = 8211173, upload-time = "2026-06-02T11:53:36.602Z" }, - { url = "https://files.pythonhosted.org/packages/cf/fd/3c0863792e98e67e9184aa4029288a175935eb65443afcd30d4f143450cf/scikit_learn-1.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:26e22435f63bcdcf396b574273f29f13dd531f5ea035801f5be10ba1540a4e60", size = 7867451, upload-time = "2026-06-02T11:53:39.075Z" }, - { url = "https://files.pythonhosted.org/packages/3c/01/cf3310626b6d48d3e9be69a1223f9180360b5e6edb045f50fade723ce494/scikit_learn-1.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:80746d63bd4b6eaca54d36fe5feaf4d28bb38dc6f9470f81c7cad7c40155f119", size = 8705188, upload-time = "2026-06-02T11:53:41.964Z" }, - { url = "https://files.pythonhosted.org/packages/3e/04/5acd7ae280c5f93b6ac5ef6cdec14eef4c8d1cd91d85b3292989c94d96b1/scikit_learn-1.9.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5b934c45c252844a91d69fda3a34cff5e7307e1db10d77cb10a3980312c74713", size = 8228299, upload-time = "2026-06-02T11:53:44.817Z" }, - { url = "https://files.pythonhosted.org/packages/0c/39/ffe829a5b8ecb40a518724a997794657fdc354ada5e8fe8e64d998c0bac9/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:38c3dcb9a1ffb85505ec53d54c7b4aea0cff70050425a7760c2af661ac85df05", size = 8789690, upload-time = "2026-06-02T11:53:47.461Z" }, - { url = "https://files.pythonhosted.org/packages/1f/88/8dab5de10c638c083772a6be83a3d8106ced492f74a928c8693638e5bb50/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da76d09304a4706db7cc1e3ebaa3b6b98a67365cc11d2996c4f1e58ba47df714", size = 9087723, upload-time = "2026-06-02T11:53:50.702Z" }, - { url = "https://files.pythonhosted.org/packages/20/3f/7917ca72464038f6240ec70c29f94862d08a34a74291ae4d4ec5eb8186a0/scikit_learn-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:5808d98f15c6bf6d9d96d2348c1997392a5888ce7097e664105f930c4bca1277", size = 8184330, upload-time = "2026-06-02T11:53:53.396Z" }, - { url = "https://files.pythonhosted.org/packages/78/c7/15739eb2f61fda3c54639e9942414e5a19ad8a8d1f5a3266afad7cb7df80/scikit_learn-1.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:d77f54c017633791bc0225a43e2f8d03745fdcfe4880268fcc4df15f505dec2e", size = 7840653, upload-time = "2026-06-02T11:53:56.035Z" }, - { url = "https://files.pythonhosted.org/packages/f4/7d/c9a35cf59b20a86fec24d306f1547b78dec194b08d367ce2a3e4854169d9/scikit_learn-1.9.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9656acd4e93f74e0b66c8a36c88830a99252dfa900044d36bc2212ae89a47162", size = 8713289, upload-time = "2026-06-02T11:53:58.788Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a7/552a7821597c632b907f7bfe8f36f9f572777af8ef8a48353041cf8e091a/scikit_learn-1.9.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:24360002ae845e7866522b0a5bbf690802e7bc388cac8663502e78aa98598aa2", size = 8245141, upload-time = "2026-06-02T11:54:01.694Z" }, - { url = "https://files.pythonhosted.org/packages/7d/79/f4a0c4fe9711154cddabf913471153af79056382ddc612cfe5ee0ff4b72e/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5162ad10a418c8a282dde04c9aa06965de3e9a65f33c1440c0ae69bb1a09d913", size = 8847671, upload-time = "2026-06-02T11:54:04.448Z" }, - { url = "https://files.pythonhosted.org/packages/f0/af/4d72d9e475ac83719160c662619e4bf7b95c19507cd582e7d0167a3c3dae/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fea2cc5677ab49d6f5bade978c866da44957b712d92e9635e8b4f723013c3cb", size = 9118104, upload-time = "2026-06-02T11:54:07.205Z" }, - { url = "https://files.pythonhosted.org/packages/a2/d5/6a58eea2cb9abbb9b3f2bb8b2cfb3243d1152d69f442d256c7af71304769/scikit_learn-1.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:64fa347efc1c839c487433e40c5144d38c336e8a2b59c81aa8660373945c2673", size = 8290674, upload-time = "2026-06-02T11:54:10.087Z" }, - { url = "https://files.pythonhosted.org/packages/65/5b/d4c879cf358f1187141cf90ced473f087183489090244f50c124a2ee478b/scikit_learn-1.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:1b944b6db288f6b926e3650026ddafb988929de95d11fc2cc5fa117773c9ba42", size = 7978807, upload-time = "2026-06-02T11:54:12.769Z" }, - { url = "https://files.pythonhosted.org/packages/8a/43/bfae3121ec67ae09150d453c442c7c1cc166e9aefe056e6ab3b7728a5cfc/scikit_learn-1.9.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4ccacf04ca5f4b492158a5f28afe0ace43f81b2571e4b9a66d34848b46128949", size = 9031941, upload-time = "2026-06-02T11:54:15.436Z" }, - { url = "https://files.pythonhosted.org/packages/75/b0/20a4546eb17f3b25d3c66df15810411c14ed5065bcfab50b53c96fb627b2/scikit_learn-1.9.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ee1a8db2c18c08e34c7412d4b10be1cac214cd4ea7dc9715a6a327eb49a37c96", size = 8613528, upload-time = "2026-06-02T11:54:18.842Z" }, - { url = "https://files.pythonhosted.org/packages/18/3c/e440e039bb82cd19004edaaad00acbde0fb9b461083c3ecf37941c557312/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:147e9329ef0e39f75d4cffa02b2aa48d827832684926cd5210d9a2cb5c57246b", size = 8855050, upload-time = "2026-06-02T11:54:21.699Z" }, - { url = "https://files.pythonhosted.org/packages/43/26/b341b8dab5998da6270a3a42c2152c578501354d36f944b5856757035ef8/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bad8f8b9950321b54c965fdcbac6c6c55e79e16646b49977bcf3668d3870a1a", size = 9097190, upload-time = "2026-06-02T11:54:24.454Z" }, - { url = "https://files.pythonhosted.org/packages/fb/de/b650b4d69b84468cfa2e28a3ff7b8103743029e6446ce1a97fe060ef688c/scikit_learn-1.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:78fc56eafd4edb9575d2d8950d1dd152061abb573341a1cb7e099fc40f6c6666", size = 8963204, upload-time = "2026-06-02T11:54:27.428Z" }, - { url = "https://files.pythonhosted.org/packages/ee/f3/ff83d76d7418112e5a61326443cdda87be3545dd8d6599c95b2481a4419e/scikit_learn-1.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:051075bda8b7aab87b1906ab3d4740a1e1224a19d7b3781a576736edc94e76aa", size = 8222661, upload-time = "2026-06-02T11:54:30.192Z" }, -] - -[[package]] -name = "scipy" -version = "1.15.3" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/2f/4966032c5f8cc7e6a60f1b2e0ad686293b9474b65246b0c642e3ef3badd0/scipy-1.15.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:a345928c86d535060c9c2b25e71e87c39ab2f22fc96e9636bd74d1dbf9de448c", size = 38702770, upload-time = "2025-05-08T16:04:20.849Z" }, - { url = "https://files.pythonhosted.org/packages/a0/6e/0c3bf90fae0e910c274db43304ebe25a6b391327f3f10b5dcc638c090795/scipy-1.15.3-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:ad3432cb0f9ed87477a8d97f03b763fd1d57709f1bbde3c9369b1dff5503b253", size = 30094511, upload-time = "2025-05-08T16:04:27.103Z" }, - { url = "https://files.pythonhosted.org/packages/ea/b1/4deb37252311c1acff7f101f6453f0440794f51b6eacb1aad4459a134081/scipy-1.15.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:aef683a9ae6eb00728a542b796f52a5477b78252edede72b8327a886ab63293f", size = 22368151, upload-time = "2025-05-08T16:04:31.731Z" }, - { url = "https://files.pythonhosted.org/packages/38/7d/f457626e3cd3c29b3a49ca115a304cebb8cc6f31b04678f03b216899d3c6/scipy-1.15.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:1c832e1bd78dea67d5c16f786681b28dd695a8cb1fb90af2e27580d3d0967e92", size = 25121732, upload-time = "2025-05-08T16:04:36.596Z" }, - { url = "https://files.pythonhosted.org/packages/db/0a/92b1de4a7adc7a15dcf5bddc6e191f6f29ee663b30511ce20467ef9b82e4/scipy-1.15.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:263961f658ce2165bbd7b99fa5135195c3a12d9bef045345016b8b50c315cb82", size = 35547617, upload-time = "2025-05-08T16:04:43.546Z" }, - { url = "https://files.pythonhosted.org/packages/8e/6d/41991e503e51fc1134502694c5fa7a1671501a17ffa12716a4a9151af3df/scipy-1.15.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2abc762b0811e09a0d3258abee2d98e0c703eee49464ce0069590846f31d40", size = 37662964, upload-time = "2025-05-08T16:04:49.431Z" }, - { url = "https://files.pythonhosted.org/packages/25/e1/3df8f83cb15f3500478c889be8fb18700813b95e9e087328230b98d547ff/scipy-1.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed7284b21a7a0c8f1b6e5977ac05396c0d008b89e05498c8b7e8f4a1423bba0e", size = 37238749, upload-time = "2025-05-08T16:04:55.215Z" }, - { url = "https://files.pythonhosted.org/packages/93/3e/b3257cf446f2a3533ed7809757039016b74cd6f38271de91682aa844cfc5/scipy-1.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5380741e53df2c566f4d234b100a484b420af85deb39ea35a1cc1be84ff53a5c", size = 40022383, upload-time = "2025-05-08T16:05:01.914Z" }, - { url = "https://files.pythonhosted.org/packages/d1/84/55bc4881973d3f79b479a5a2e2df61c8c9a04fcb986a213ac9c02cfb659b/scipy-1.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:9d61e97b186a57350f6d6fd72640f9e99d5a4a2b8fbf4b9ee9a841eab327dc13", size = 41259201, upload-time = "2025-05-08T16:05:08.166Z" }, - { url = "https://files.pythonhosted.org/packages/96/ab/5cc9f80f28f6a7dff646c5756e559823614a42b1939d86dd0ed550470210/scipy-1.15.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:993439ce220d25e3696d1b23b233dd010169b62f6456488567e830654ee37a6b", size = 38714255, upload-time = "2025-05-08T16:05:14.596Z" }, - { url = "https://files.pythonhosted.org/packages/4a/4a/66ba30abe5ad1a3ad15bfb0b59d22174012e8056ff448cb1644deccbfed2/scipy-1.15.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:34716e281f181a02341ddeaad584205bd2fd3c242063bd3423d61ac259ca7eba", size = 30111035, upload-time = "2025-05-08T16:05:20.152Z" }, - { url = "https://files.pythonhosted.org/packages/4b/fa/a7e5b95afd80d24313307f03624acc65801846fa75599034f8ceb9e2cbf6/scipy-1.15.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3b0334816afb8b91dab859281b1b9786934392aa3d527cd847e41bb6f45bee65", size = 22384499, upload-time = "2025-05-08T16:05:24.494Z" }, - { url = "https://files.pythonhosted.org/packages/17/99/f3aaddccf3588bb4aea70ba35328c204cadd89517a1612ecfda5b2dd9d7a/scipy-1.15.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:6db907c7368e3092e24919b5e31c76998b0ce1684d51a90943cb0ed1b4ffd6c1", size = 25152602, upload-time = "2025-05-08T16:05:29.313Z" }, - { url = "https://files.pythonhosted.org/packages/56/c5/1032cdb565f146109212153339f9cb8b993701e9fe56b1c97699eee12586/scipy-1.15.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:721d6b4ef5dc82ca8968c25b111e307083d7ca9091bc38163fb89243e85e3889", size = 35503415, upload-time = "2025-05-08T16:05:34.699Z" }, - { url = "https://files.pythonhosted.org/packages/bd/37/89f19c8c05505d0601ed5650156e50eb881ae3918786c8fd7262b4ee66d3/scipy-1.15.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39cb9c62e471b1bb3750066ecc3a3f3052b37751c7c3dfd0fd7e48900ed52982", size = 37652622, upload-time = "2025-05-08T16:05:40.762Z" }, - { url = "https://files.pythonhosted.org/packages/7e/31/be59513aa9695519b18e1851bb9e487de66f2d31f835201f1b42f5d4d475/scipy-1.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:795c46999bae845966368a3c013e0e00947932d68e235702b5c3f6ea799aa8c9", size = 37244796, upload-time = "2025-05-08T16:05:48.119Z" }, - { url = "https://files.pythonhosted.org/packages/10/c0/4f5f3eeccc235632aab79b27a74a9130c6c35df358129f7ac8b29f562ac7/scipy-1.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:18aaacb735ab38b38db42cb01f6b92a2d0d4b6aabefeb07f02849e47f8fb3594", size = 40047684, upload-time = "2025-05-08T16:05:54.22Z" }, - { url = "https://files.pythonhosted.org/packages/ab/a7/0ddaf514ce8a8714f6ed243a2b391b41dbb65251affe21ee3077ec45ea9a/scipy-1.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:ae48a786a28412d744c62fd7816a4118ef97e5be0bee968ce8f0a2fba7acf3bb", size = 41246504, upload-time = "2025-05-08T16:06:00.437Z" }, - { url = "https://files.pythonhosted.org/packages/37/4b/683aa044c4162e10ed7a7ea30527f2cbd92e6999c10a8ed8edb253836e9c/scipy-1.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6ac6310fdbfb7aa6612408bd2f07295bcbd3fda00d2d702178434751fe48e019", size = 38766735, upload-time = "2025-05-08T16:06:06.471Z" }, - { url = "https://files.pythonhosted.org/packages/7b/7e/f30be3d03de07f25dc0ec926d1681fed5c732d759ac8f51079708c79e680/scipy-1.15.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:185cd3d6d05ca4b44a8f1595af87f9c372bb6acf9c808e99aa3e9aa03bd98cf6", size = 30173284, upload-time = "2025-05-08T16:06:11.686Z" }, - { url = "https://files.pythonhosted.org/packages/07/9c/0ddb0d0abdabe0d181c1793db51f02cd59e4901da6f9f7848e1f96759f0d/scipy-1.15.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:05dc6abcd105e1a29f95eada46d4a3f251743cfd7d3ae8ddb4088047f24ea477", size = 22446958, upload-time = "2025-05-08T16:06:15.97Z" }, - { url = "https://files.pythonhosted.org/packages/af/43/0bce905a965f36c58ff80d8bea33f1f9351b05fad4beaad4eae34699b7a1/scipy-1.15.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:06efcba926324df1696931a57a176c80848ccd67ce6ad020c810736bfd58eb1c", size = 25242454, upload-time = "2025-05-08T16:06:20.394Z" }, - { url = "https://files.pythonhosted.org/packages/56/30/a6f08f84ee5b7b28b4c597aca4cbe545535c39fe911845a96414700b64ba/scipy-1.15.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05045d8b9bfd807ee1b9f38761993297b10b245f012b11b13b91ba8945f7e45", size = 35210199, upload-time = "2025-05-08T16:06:26.159Z" }, - { url = "https://files.pythonhosted.org/packages/0b/1f/03f52c282437a168ee2c7c14a1a0d0781a9a4a8962d84ac05c06b4c5b555/scipy-1.15.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271e3713e645149ea5ea3e97b57fdab61ce61333f97cfae392c28ba786f9bb49", size = 37309455, upload-time = "2025-05-08T16:06:32.778Z" }, - { url = "https://files.pythonhosted.org/packages/89/b1/fbb53137f42c4bf630b1ffdfc2151a62d1d1b903b249f030d2b1c0280af8/scipy-1.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cfd56fc1a8e53f6e89ba3a7a7251f7396412d655bca2aa5611c8ec9a6784a1e", size = 36885140, upload-time = "2025-05-08T16:06:39.249Z" }, - { url = "https://files.pythonhosted.org/packages/2e/2e/025e39e339f5090df1ff266d021892694dbb7e63568edcfe43f892fa381d/scipy-1.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ff17c0bb1cb32952c09217d8d1eed9b53d1463e5f1dd6052c7857f83127d539", size = 39710549, upload-time = "2025-05-08T16:06:45.729Z" }, - { url = "https://files.pythonhosted.org/packages/e6/eb/3bf6ea8ab7f1503dca3a10df2e4b9c3f6b3316df07f6c0ded94b281c7101/scipy-1.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:52092bc0472cfd17df49ff17e70624345efece4e1a12b23783a1ac59a1b728ed", size = 40966184, upload-time = "2025-05-08T16:06:52.623Z" }, - { url = "https://files.pythonhosted.org/packages/73/18/ec27848c9baae6e0d6573eda6e01a602e5649ee72c27c3a8aad673ebecfd/scipy-1.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c620736bcc334782e24d173c0fdbb7590a0a436d2fdf39310a8902505008759", size = 38728256, upload-time = "2025-05-08T16:06:58.696Z" }, - { url = "https://files.pythonhosted.org/packages/74/cd/1aef2184948728b4b6e21267d53b3339762c285a46a274ebb7863c9e4742/scipy-1.15.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:7e11270a000969409d37ed399585ee530b9ef6aa99d50c019de4cb01e8e54e62", size = 30109540, upload-time = "2025-05-08T16:07:04.209Z" }, - { url = "https://files.pythonhosted.org/packages/5b/d8/59e452c0a255ec352bd0a833537a3bc1bfb679944c4938ab375b0a6b3a3e/scipy-1.15.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8c9ed3ba2c8a2ce098163a9bdb26f891746d02136995df25227a20e71c396ebb", size = 22383115, upload-time = "2025-05-08T16:07:08.998Z" }, - { url = "https://files.pythonhosted.org/packages/08/f5/456f56bbbfccf696263b47095291040655e3cbaf05d063bdc7c7517f32ac/scipy-1.15.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:0bdd905264c0c9cfa74a4772cdb2070171790381a5c4d312c973382fc6eaf730", size = 25163884, upload-time = "2025-05-08T16:07:14.091Z" }, - { url = "https://files.pythonhosted.org/packages/a2/66/a9618b6a435a0f0c0b8a6d0a2efb32d4ec5a85f023c2b79d39512040355b/scipy-1.15.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79167bba085c31f38603e11a267d862957cbb3ce018d8b38f79ac043bc92d825", size = 35174018, upload-time = "2025-05-08T16:07:19.427Z" }, - { url = "https://files.pythonhosted.org/packages/b5/09/c5b6734a50ad4882432b6bb7c02baf757f5b2f256041da5df242e2d7e6b6/scipy-1.15.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9deabd6d547aee2c9a81dee6cc96c6d7e9a9b1953f74850c179f91fdc729cb7", size = 37269716, upload-time = "2025-05-08T16:07:25.712Z" }, - { url = "https://files.pythonhosted.org/packages/77/0a/eac00ff741f23bcabd352731ed9b8995a0a60ef57f5fd788d611d43d69a1/scipy-1.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dde4fc32993071ac0c7dd2d82569e544f0bdaff66269cb475e0f369adad13f11", size = 36872342, upload-time = "2025-05-08T16:07:31.468Z" }, - { url = "https://files.pythonhosted.org/packages/fe/54/4379be86dd74b6ad81551689107360d9a3e18f24d20767a2d5b9253a3f0a/scipy-1.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f77f853d584e72e874d87357ad70f44b437331507d1c311457bed8ed2b956126", size = 39670869, upload-time = "2025-05-08T16:07:38.002Z" }, - { url = "https://files.pythonhosted.org/packages/87/2e/892ad2862ba54f084ffe8cc4a22667eaf9c2bcec6d2bff1d15713c6c0703/scipy-1.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:b90ab29d0c37ec9bf55424c064312930ca5f4bde15ee8619ee44e69319aab163", size = 40988851, upload-time = "2025-05-08T16:08:33.671Z" }, - { url = "https://files.pythonhosted.org/packages/1b/e9/7a879c137f7e55b30d75d90ce3eb468197646bc7b443ac036ae3fe109055/scipy-1.15.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3ac07623267feb3ae308487c260ac684b32ea35fd81e12845039952f558047b8", size = 38863011, upload-time = "2025-05-08T16:07:44.039Z" }, - { url = "https://files.pythonhosted.org/packages/51/d1/226a806bbd69f62ce5ef5f3ffadc35286e9fbc802f606a07eb83bf2359de/scipy-1.15.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6487aa99c2a3d509a5227d9a5e889ff05830a06b2ce08ec30df6d79db5fcd5c5", size = 30266407, upload-time = "2025-05-08T16:07:49.891Z" }, - { url = "https://files.pythonhosted.org/packages/e5/9b/f32d1d6093ab9eeabbd839b0f7619c62e46cc4b7b6dbf05b6e615bbd4400/scipy-1.15.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:50f9e62461c95d933d5c5ef4a1f2ebf9a2b4e83b0db374cb3f1de104d935922e", size = 22540030, upload-time = "2025-05-08T16:07:54.121Z" }, - { url = "https://files.pythonhosted.org/packages/e7/29/c278f699b095c1a884f29fda126340fcc201461ee8bfea5c8bdb1c7c958b/scipy-1.15.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:14ed70039d182f411ffc74789a16df3835e05dc469b898233a245cdfd7f162cb", size = 25218709, upload-time = "2025-05-08T16:07:58.506Z" }, - { url = "https://files.pythonhosted.org/packages/24/18/9e5374b617aba742a990581373cd6b68a2945d65cc588482749ef2e64467/scipy-1.15.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a769105537aa07a69468a0eefcd121be52006db61cdd8cac8a0e68980bbb723", size = 34809045, upload-time = "2025-05-08T16:08:03.929Z" }, - { url = "https://files.pythonhosted.org/packages/e1/fe/9c4361e7ba2927074360856db6135ef4904d505e9b3afbbcb073c4008328/scipy-1.15.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db984639887e3dffb3928d118145ffe40eff2fa40cb241a306ec57c219ebbbb", size = 36703062, upload-time = "2025-05-08T16:08:09.558Z" }, - { url = "https://files.pythonhosted.org/packages/b7/8e/038ccfe29d272b30086b25a4960f757f97122cb2ec42e62b460d02fe98e9/scipy-1.15.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:40e54d5c7e7ebf1aa596c374c49fa3135f04648a0caabcb66c52884b943f02b4", size = 36393132, upload-time = "2025-05-08T16:08:15.34Z" }, - { url = "https://files.pythonhosted.org/packages/10/7e/5c12285452970be5bdbe8352c619250b97ebf7917d7a9a9e96b8a8140f17/scipy-1.15.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5e721fed53187e71d0ccf382b6bf977644c533e506c4d33c3fb24de89f5c3ed5", size = 38979503, upload-time = "2025-05-08T16:08:21.513Z" }, - { url = "https://files.pythonhosted.org/packages/81/06/0a5e5349474e1cbc5757975b21bd4fad0e72ebf138c5592f191646154e06/scipy-1.15.3-cp313-cp313t-win_amd64.whl", hash = "sha256:76ad1fb5f8752eabf0fa02e4cc0336b4e8f021e2d5f061ed37d6d264db35e3ca", size = 40308097, upload-time = "2025-05-08T16:08:27.627Z" }, -] - -[[package]] -name = "scipy" -version = "1.17.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version == '3.14.*'", - "python_full_version == '3.13.*'", - "python_full_version >= '3.11' and python_full_version < '3.13'", -] -dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, - { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, - { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, - { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, - { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, - { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, - { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, - { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, - { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, - { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, - { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, - { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, - { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, - { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, - { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, - { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, - { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, - { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, - { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, - { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, - { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, - { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, - { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, - { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, - { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, - { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, - { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, - { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, - { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, - { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, - { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, - { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, - { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, - { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, - { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, - { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, - { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, - { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, - { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, - { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, - { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, - { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, - { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, - { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, - { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, - { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, - { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, - { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, - { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, - { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, - { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, - { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, - { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, -] - -[[package]] -name = "sentence-transformers" -version = "5.5.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "huggingface-hub" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "scikit-learn", version = "1.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "torch" }, - { name = "tqdm" }, - { name = "transformers" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cf/d4/7ef93157485e978c016f49da05363c1e4e7237beb5343b64b5631101f0f1/sentence_transformers-5.5.1.tar.gz", hash = "sha256:02b7740dfc60bdbbcb6061625f5d97a5c1a4e2d3baac5f9391b912bb5eae2290", size = 445161, upload-time = "2026-05-20T07:37:44.465Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/03/ee99a6b030e7a2e056547729f8a4709dd93e13d9c6f07590f74c395c4017/sentence_transformers-5.5.1-py3-none-any.whl", hash = "sha256:4fe11d433badc5282d32f7fc08bc714216b7a5aca426f9df77a45a554756deb7", size = 588887, upload-time = "2026-05-20T07:37:43.004Z" }, -] - -[[package]] -name = "setuptools" -version = "81.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0d/1c/73e719955c59b8e424d015ab450f51c0af856ae46ea2da83eba51cc88de1/setuptools-81.0.0.tar.gz", hash = "sha256:487b53915f52501f0a79ccfd0c02c165ffe06631443a886740b91af4b7a5845a", size = 1198299, upload-time = "2026-02-06T21:10:39.601Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" }, -] - -[[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 = "six" version = "1.17.0" @@ -5784,15 +3561,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/3e/b0fbd7621e6430d33d234ad5e2dc9b0d4df575518f4790a2af934e1029ea/sortedcontainers_stubs-2.4.3-py3-none-any.whl", hash = "sha256:4496109dfa6645e4b675f57fbc7e42ec4d1bed2c74aab7fa379e0795e49fe406", size = 8816, upload-time = "2025-04-23T07:41:58.625Z" }, ] -[[package]] -name = "soupsieve" -version = "2.8.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, -] - [[package]] name = "sqlite-vec" version = "0.1.9" @@ -5854,18 +3622,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/86/16/426f1f8c7d2678230b68755fcb3c0b8f59bc3a5591efe48343566b8e73c5/strands_agents-1.42.0-py3-none-any.whl", hash = "sha256:6cc04a32fc23a443a651d0e40198d946afc809888ae6fbc1fd07b2d5b6e354b3", size = 440999, upload-time = "2026-06-01T18:38:20.231Z" }, ] -[[package]] -name = "sympy" -version = "1.14.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mpmath" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, -] - [[package]] name = "tenacity" version = "8.5.0" @@ -5875,123 +3631,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/3f/8ba87d9e287b9d385a02a7114ddcef61b26f86411e121c9003eb509a1773/tenacity-8.5.0-py3-none-any.whl", hash = "sha256:b594c2a5945830c267ce6b79a166228323ed52718f30302c1359836112346687", size = 28165, upload-time = "2024-07-05T07:25:29.591Z" }, ] -[[package]] -name = "textual" -version = "8.2.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py", extra = ["linkify"] }, - { name = "mdit-py-plugins" }, - { name = "platformdirs" }, - { name = "pygments" }, - { name = "rich" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9b/7a/c519db0aba5024f86e71e9631810bfdd6866ed2c8695bd7fa34b90e7ef59/textual-8.2.7.tar.gz", hash = "sha256:658f568ff81e30ed43890c3e07520390e5cf1b4763822006e060656b0a88f105", size = 1859249, upload-time = "2026-05-19T10:52:49.531Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/f5/c1e18bc0707300a0e90204343abbf7d7acd6fb7ebe03a6d4893b99a234b8/textual-8.2.7-py3-none-any.whl", hash = "sha256:4caaa13a90bc4cf9c6c862c067ccd34fe84e9c161710a2a907a8026313b6bd73", size = 731129, upload-time = "2026-05-19T10:52:51.773Z" }, -] - -[[package]] -name = "threadpoolctl" -version = "3.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, -] - -[[package]] -name = "tiktoken" -version = "0.12.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "regex" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/89/b3/2cb7c17b6c4cf8ca983204255d3f1d95eda7213e247e6947a0ee2c747a2c/tiktoken-0.12.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3de02f5a491cfd179aec916eddb70331814bd6bf764075d39e21d5862e533970", size = 1051991, upload-time = "2025-10-06T20:21:34.098Z" }, - { url = "https://files.pythonhosted.org/packages/27/0f/df139f1df5f6167194ee5ab24634582ba9a1b62c6b996472b0277ec80f66/tiktoken-0.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b6cfb6d9b7b54d20af21a912bfe63a2727d9cfa8fbda642fd8322c70340aad16", size = 995798, upload-time = "2025-10-06T20:21:35.579Z" }, - { url = "https://files.pythonhosted.org/packages/ef/5d/26a691f28ab220d5edc09b9b787399b130f24327ef824de15e5d85ef21aa/tiktoken-0.12.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:cde24cdb1b8a08368f709124f15b36ab5524aac5fa830cc3fdce9c03d4fb8030", size = 1129865, upload-time = "2025-10-06T20:21:36.675Z" }, - { url = "https://files.pythonhosted.org/packages/b2/94/443fab3d4e5ebecac895712abd3849b8da93b7b7dec61c7db5c9c7ebe40c/tiktoken-0.12.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:6de0da39f605992649b9cfa6f84071e3f9ef2cec458d08c5feb1b6f0ff62e134", size = 1152856, upload-time = "2025-10-06T20:21:37.873Z" }, - { url = "https://files.pythonhosted.org/packages/54/35/388f941251b2521c70dd4c5958e598ea6d2c88e28445d2fb8189eecc1dfc/tiktoken-0.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6faa0534e0eefbcafaccb75927a4a380463a2eaa7e26000f0173b920e98b720a", size = 1195308, upload-time = "2025-10-06T20:21:39.577Z" }, - { url = "https://files.pythonhosted.org/packages/f8/00/c6681c7f833dd410576183715a530437a9873fa910265817081f65f9105f/tiktoken-0.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:82991e04fc860afb933efb63957affc7ad54f83e2216fe7d319007dab1ba5892", size = 1255697, upload-time = "2025-10-06T20:21:41.154Z" }, - { url = "https://files.pythonhosted.org/packages/5f/d2/82e795a6a9bafa034bf26a58e68fe9a89eeaaa610d51dbeb22106ba04f0a/tiktoken-0.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:6fb2995b487c2e31acf0a9e17647e3b242235a20832642bb7a9d1a181c0c1bb1", size = 879375, upload-time = "2025-10-06T20:21:43.201Z" }, - { url = "https://files.pythonhosted.org/packages/de/46/21ea696b21f1d6d1efec8639c204bdf20fde8bafb351e1355c72c5d7de52/tiktoken-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e227c7f96925003487c33b1b32265fad2fbcec2b7cf4817afb76d416f40f6bb", size = 1051565, upload-time = "2025-10-06T20:21:44.566Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d9/35c5d2d9e22bb2a5f74ba48266fb56c63d76ae6f66e02feb628671c0283e/tiktoken-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c06cf0fcc24c2cb2adb5e185c7082a82cba29c17575e828518c2f11a01f445aa", size = 995284, upload-time = "2025-10-06T20:21:45.622Z" }, - { url = "https://files.pythonhosted.org/packages/01/84/961106c37b8e49b9fdcf33fe007bb3a8fdcc380c528b20cc7fbba80578b8/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f18f249b041851954217e9fd8e5c00b024ab2315ffda5ed77665a05fa91f42dc", size = 1129201, upload-time = "2025-10-06T20:21:47.074Z" }, - { url = "https://files.pythonhosted.org/packages/6a/d0/3d9275198e067f8b65076a68894bb52fd253875f3644f0a321a720277b8a/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:47a5bc270b8c3db00bb46ece01ef34ad050e364b51d406b6f9730b64ac28eded", size = 1152444, upload-time = "2025-10-06T20:21:48.139Z" }, - { url = "https://files.pythonhosted.org/packages/78/db/a58e09687c1698a7c592e1038e01c206569b86a0377828d51635561f8ebf/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:508fa71810c0efdcd1b898fda574889ee62852989f7c1667414736bcb2b9a4bd", size = 1195080, upload-time = "2025-10-06T20:21:49.246Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1b/a9e4d2bf91d515c0f74afc526fd773a812232dd6cda33ebea7f531202325/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a1af81a6c44f008cba48494089dd98cccb8b313f55e961a52f5b222d1e507967", size = 1255240, upload-time = "2025-10-06T20:21:50.274Z" }, - { url = "https://files.pythonhosted.org/packages/9d/15/963819345f1b1fb0809070a79e9dd96938d4ca41297367d471733e79c76c/tiktoken-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:3e68e3e593637b53e56f7237be560f7a394451cb8c11079755e80ae64b9e6def", size = 879422, upload-time = "2025-10-06T20:21:51.734Z" }, - { url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728, upload-time = "2025-10-06T20:21:52.756Z" }, - { url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049, upload-time = "2025-10-06T20:21:53.782Z" }, - { url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008, upload-time = "2025-10-06T20:21:54.832Z" }, - { url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665, upload-time = "2025-10-06T20:21:56.129Z" }, - { url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230, upload-time = "2025-10-06T20:21:57.546Z" }, - { url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688, upload-time = "2025-10-06T20:21:58.619Z" }, - { url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694, upload-time = "2025-10-06T20:21:59.876Z" }, - { url = "https://files.pythonhosted.org/packages/00/61/441588ee21e6b5cdf59d6870f86beb9789e532ee9718c251b391b70c68d6/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3", size = 1050802, upload-time = "2025-10-06T20:22:00.96Z" }, - { url = "https://files.pythonhosted.org/packages/1f/05/dcf94486d5c5c8d34496abe271ac76c5b785507c8eae71b3708f1ad9b45a/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160", size = 993995, upload-time = "2025-10-06T20:22:02.788Z" }, - { url = "https://files.pythonhosted.org/packages/a0/70/5163fe5359b943f8db9946b62f19be2305de8c3d78a16f629d4165e2f40e/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa", size = 1128948, upload-time = "2025-10-06T20:22:03.814Z" }, - { url = "https://files.pythonhosted.org/packages/0c/da/c028aa0babf77315e1cef357d4d768800c5f8a6de04d0eac0f377cb619fa/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be", size = 1151986, upload-time = "2025-10-06T20:22:05.173Z" }, - { url = "https://files.pythonhosted.org/packages/a0/5a/886b108b766aa53e295f7216b509be95eb7d60b166049ce2c58416b25f2a/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a", size = 1194222, upload-time = "2025-10-06T20:22:06.265Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f8/4db272048397636ac7a078d22773dd2795b1becee7bc4922fe6207288d57/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3", size = 1255097, upload-time = "2025-10-06T20:22:07.403Z" }, - { url = "https://files.pythonhosted.org/packages/8e/32/45d02e2e0ea2be3a9ed22afc47d93741247e75018aac967b713b2941f8ea/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697", size = 879117, upload-time = "2025-10-06T20:22:08.418Z" }, - { url = "https://files.pythonhosted.org/packages/ce/76/994fc868f88e016e6d05b0da5ac24582a14c47893f4474c3e9744283f1d5/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16", size = 1050309, upload-time = "2025-10-06T20:22:10.939Z" }, - { url = "https://files.pythonhosted.org/packages/f6/b8/57ef1456504c43a849821920d582a738a461b76a047f352f18c0b26c6516/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a", size = 993712, upload-time = "2025-10-06T20:22:12.115Z" }, - { url = "https://files.pythonhosted.org/packages/72/90/13da56f664286ffbae9dbcfadcc625439142675845baa62715e49b87b68b/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27", size = 1128725, upload-time = "2025-10-06T20:22:13.541Z" }, - { url = "https://files.pythonhosted.org/packages/05/df/4f80030d44682235bdaecd7346c90f67ae87ec8f3df4a3442cb53834f7e4/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb", size = 1151875, upload-time = "2025-10-06T20:22:14.559Z" }, - { url = "https://files.pythonhosted.org/packages/22/1f/ae535223a8c4ef4c0c1192e3f9b82da660be9eb66b9279e95c99288e9dab/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e", size = 1194451, upload-time = "2025-10-06T20:22:15.545Z" }, - { url = "https://files.pythonhosted.org/packages/78/a7/f8ead382fce0243cb625c4f266e66c27f65ae65ee9e77f59ea1653b6d730/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25", size = 1253794, upload-time = "2025-10-06T20:22:16.624Z" }, - { url = "https://files.pythonhosted.org/packages/93/e0/6cc82a562bc6365785a3ff0af27a2a092d57c47d7a81d9e2295d8c36f011/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f", size = 878777, upload-time = "2025-10-06T20:22:18.036Z" }, - { url = "https://files.pythonhosted.org/packages/72/05/3abc1db5d2c9aadc4d2c76fa5640134e475e58d9fbb82b5c535dc0de9b01/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646", size = 1050188, upload-time = "2025-10-06T20:22:19.563Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7b/50c2f060412202d6c95f32b20755c7a6273543b125c0985d6fa9465105af/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88", size = 993978, upload-time = "2025-10-06T20:22:20.702Z" }, - { url = "https://files.pythonhosted.org/packages/14/27/bf795595a2b897e271771cd31cb847d479073497344c637966bdf2853da1/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff", size = 1129271, upload-time = "2025-10-06T20:22:22.06Z" }, - { url = "https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830", size = 1151216, upload-time = "2025-10-06T20:22:23.085Z" }, - { url = "https://files.pythonhosted.org/packages/75/0d/881866647b8d1be4d67cb24e50d0c26f9f807f994aa1510cb9ba2fe5f612/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b", size = 1194860, upload-time = "2025-10-06T20:22:24.602Z" }, - { url = "https://files.pythonhosted.org/packages/b3/1e/b651ec3059474dab649b8d5b69f5c65cd8fcd8918568c1935bd4136c9392/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b", size = 1254567, upload-time = "2025-10-06T20:22:25.671Z" }, - { url = "https://files.pythonhosted.org/packages/80/57/ce64fd16ac390fafde001268c364d559447ba09b509181b2808622420eec/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3", size = 921067, upload-time = "2025-10-06T20:22:26.753Z" }, - { url = "https://files.pythonhosted.org/packages/ac/a4/72eed53e8976a099539cdd5eb36f241987212c29629d0a52c305173e0a68/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365", size = 1050473, upload-time = "2025-10-06T20:22:27.775Z" }, - { url = "https://files.pythonhosted.org/packages/e6/d7/0110b8f54c008466b19672c615f2168896b83706a6611ba6e47313dbc6e9/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e", size = 993855, upload-time = "2025-10-06T20:22:28.799Z" }, - { url = "https://files.pythonhosted.org/packages/5f/77/4f268c41a3957c418b084dd576ea2fad2e95da0d8e1ab705372892c2ca22/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63", size = 1129022, upload-time = "2025-10-06T20:22:29.981Z" }, - { url = "https://files.pythonhosted.org/packages/4e/2b/fc46c90fe5028bd094cd6ee25a7db321cb91d45dc87531e2bdbb26b4867a/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0", size = 1150736, upload-time = "2025-10-06T20:22:30.996Z" }, - { url = "https://files.pythonhosted.org/packages/28/c0/3c7a39ff68022ddfd7d93f3337ad90389a342f761c4d71de99a3ccc57857/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a", size = 1194908, upload-time = "2025-10-06T20:22:32.073Z" }, - { url = "https://files.pythonhosted.org/packages/ab/0d/c1ad6f4016a3968c048545f5d9b8ffebf577774b2ede3e2e352553b685fe/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0", size = 1253706, upload-time = "2025-10-06T20:22:33.385Z" }, - { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" }, -] - -[[package]] -name = "tokenizers" -version = "0.22.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "huggingface-hub" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" }, - { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" }, - { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" }, - { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" }, - { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" }, - { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" }, - { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" }, - { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" }, - { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" }, - { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" }, - { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" }, - { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" }, - { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" }, - { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" }, - { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, - { url = "https://files.pythonhosted.org/packages/84/04/655b79dbcc9b3ac5f1479f18e931a344af67e5b7d3b251d2dcdcd7558592/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:753d47ebd4542742ef9261d9da92cd545b2cacbb48349a1225466745bb866ec4", size = 3282301, upload-time = "2026-01-05T10:40:34.858Z" }, - { url = "https://files.pythonhosted.org/packages/46/cd/e4851401f3d8f6f45d8480262ab6a5c8cb9c4302a790a35aa14eeed6d2fd/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e10bf9113d209be7cd046d40fbabbaf3278ff6d18eb4da4c500443185dc1896c", size = 3161308, upload-time = "2026-01-05T10:40:40.737Z" }, - { url = "https://files.pythonhosted.org/packages/6f/6e/55553992a89982cd12d4a66dddb5e02126c58677ea3931efcbe601d419db/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:64d94e84f6660764e64e7e0b22baa72f6cd942279fdbb21d46abd70d179f0195", size = 3718964, upload-time = "2026-01-05T10:40:46.56Z" }, - { url = "https://files.pythonhosted.org/packages/59/8c/b1c87148aa15e099243ec9f0cf9d0e970cc2234c3257d558c25a2c5304e6/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f01a9c019878532f98927d2bacb79bbb404b43d3437455522a00a30718cdedb5", size = 3373542, upload-time = "2026-01-05T10:40:52.803Z" }, -] - [[package]] name = "tomli" version = "2.0.2" @@ -6001,15 +3640,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cf/db/ce8eda256fa131af12e0a76d481711abe4681b6923c27efb9a255c9e4594/tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38", size = 13237, upload-time = "2024-10-02T10:46:11.806Z" }, ] -[[package]] -name = "tomli-w" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d4/19/b65f1a088ee23e37cdea415b357843eca8b1422a7b11a9eee6e35d4ec273/tomli_w-1.1.0.tar.gz", hash = "sha256:49e847a3a304d516a169a601184932ef0f6b61623fe680f836a2aa7128ed0d33", size = 6929, upload-time = "2024-10-08T11:13:29.279Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/ac/ce90573ba446a9bbe65838ded066a805234d159b4446ae9f8ec5bbd36cbd/tomli_w-1.1.0-py3-none-any.whl", hash = "sha256:1403179c78193e3184bfaade390ddbd071cba48a32a2e62ba11aae47490c63f7", size = 6440, upload-time = "2024-10-08T11:13:27.897Z" }, -] - [[package]] name = "tomlkit" version = "0.15.0" @@ -6019,128 +3649,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" }, ] -[[package]] -name = "torch" -version = "2.12.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cuda-bindings", marker = "sys_platform == 'linux'" }, - { name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" }, - { name = "filelock" }, - { name = "fsspec" }, - { name = "jinja2" }, - { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "nvidia-cublas", marker = "sys_platform == 'linux'" }, - { name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" }, - { name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" }, - { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" }, - { name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" }, - { name = "setuptools" }, - { name = "sympy" }, - { name = "triton", marker = "sys_platform == 'linux'" }, - { name = "typing-extensions" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/b7/53fe0436586716ab7aecff41e26b9302d57c85ded481fd83a2cd741e6b4e/torch-2.12.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:1834bd984f8a2f4f16bdfbeecca9146184b220aa46276bf5756735b5dae12812", size = 87981887, upload-time = "2026-05-13T14:55:53.234Z" }, - { url = "https://files.pythonhosted.org/packages/34/60/d930eac44c30de06ed16f6d1ba4e785e1632532b50d8f0bf9bf699a4d0c7/torch-2.12.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:d4d029801cb7b6df858804a2a21b00cc2aa0bf0ee5d2ab18d343c9e9e5681f35", size = 426355000, upload-time = "2026-05-13T14:54:31.944Z" }, - { url = "https://files.pythonhosted.org/packages/8e/0c/c76b6a087820bab55705b94dfc074e520de9ae91f5ef90da2ecbf2a3ef12/torch-2.12.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:d47e7dee68ac4cd7a068b26bcd6b989935427709fae1c8f7bd0019978f829e15", size = 532144998, upload-time = "2026-05-13T14:56:05.523Z" }, - { url = "https://files.pythonhosted.org/packages/4a/64/8a0d036e166a6aa85ee09bef072f3655d1ba5d5486a68d1b03b6813c01b3/torch-2.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:cf9839790285dd472e7a16aafcb4a4e6bf58ec1b494045044b0eefb0eb4bd1f2", size = 122949877, upload-time = "2026-05-13T14:55:46.841Z" }, - { url = "https://files.pythonhosted.org/packages/18/62/131124fb95df03811b8260d1d43dcc5ee85ea1a344b964613d7efe77fb08/torch-2.12.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:10802fd383bbfed646212e765a72c37d2185205d4f26eb197a254e8ac7ddcb25", size = 87990344, upload-time = "2026-05-13T14:55:42.154Z" }, - { url = "https://files.pythonhosted.org/packages/12/9c/dda0dbd547dc549839824135f223792fd0e725f28ed0715dda366b7acaa2/torch-2.12.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:c12592630aef72feaf18bd3f197ef587bbfa21131b31c38b23ab2e55fce92e36", size = 426362932, upload-time = "2026-05-13T14:54:15.295Z" }, - { url = "https://files.pythonhosted.org/packages/e2/d2/a7dd5a3f9bdaa7842124e8e2359202b317c48d47d2fc5816fafdf2049adb/torch-2.12.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:415c1b8d0412f67551c8e89a2daca0fb3e56694af0281ba155eaa9da481f58b4", size = 532170085, upload-time = "2026-05-13T14:55:20.788Z" }, - { url = "https://files.pythonhosted.org/packages/12/1b/a61ce2004f9ab0ea8964a6e6168133a127795667639e2ff4f8f2bdb16a65/torch-2.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:dd37188ea325042cb1f6cafa56822b11ada2520c04791a52629b0af25bdfbfd9", size = 122953128, upload-time = "2026-05-13T14:54:52.744Z" }, - { url = "https://files.pythonhosted.org/packages/ef/bb/285d643f254731294c9b595a007eac39db4600a98682d7bca688f42ca164/torch-2.12.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b41339df93d491435e790ff8bcbae1c0ce777175889bfd1281d119862793e6a2", size = 88010197, upload-time = "2026-05-13T14:55:35.414Z" }, - { url = "https://files.pythonhosted.org/packages/79/81/76debf1db1343bd929bbb5d74c89fb437c2ed88eb144712557e7bd3eea45/torch-2.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:8fbef9f108a863e7722a73740998967e3b074742a834fc5be3a535a2befa7057", size = 426376751, upload-time = "2026-05-13T14:55:03.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/f0/80026028b603c4650ff270fc3785bdef4bd6738765a9cc5a0f5a637d65a2/torch-2.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:4b4f64c2c2b11f7510d93dd6412b87025ff6eddd6bb61c3b5a3d892ea20c4756", size = 532261691, upload-time = "2026-05-13T14:52:54.453Z" }, - { url = "https://files.pythonhosted.org/packages/b9/c2/64b06cbb7830fb3cd9be13e1158b31a3f36b68e6a209105ee3c9d9480be0/torch-2.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:8b958caff4a14d3a3b0b2dfc6a378f64dda9728a9dad28c08a0db9ce4dafb549", size = 122988114, upload-time = "2026-05-13T14:54:42.153Z" }, - { url = "https://files.pythonhosted.org/packages/86/ca/01896c80ba921676aa45886b2c5b8d774912de2a1f719de48169c6f755cd/torch-2.12.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:90dd587a5f61bfe1307148b581e2084fc5bc4a06e2b90a20e9a36b81087ff16b", size = 88009511, upload-time = "2026-05-13T14:54:47.411Z" }, - { url = "https://files.pythonhosted.org/packages/a5/04/52bdaf4787eab6ac7d7f5851dff934e4def0bc8ead9c8fd2b69b3e529699/torch-2.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:864392c73b7654f4d2b3ae712f607937d0dbb1101c4555fbb41848106b297f39", size = 426383231, upload-time = "2026-05-13T14:53:32.129Z" }, - { url = "https://files.pythonhosted.org/packages/49/8a/94bdecd13f5aaa90d45920b89789d9fe7c6f4af8c3cdd7ce01fcb59908fc/torch-2.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5d6b560dfa7d56291c07d615c3bb73e8d9943d9b6d87f76cd0d9d570c4797fa6", size = 532269288, upload-time = "2026-05-13T14:53:49.423Z" }, - { url = "https://files.pythonhosted.org/packages/3e/2f/bdbaaa267de519ef1b73054bf590d8c93c37a266c9a4e24a01bd38b6918f/torch-2.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:3fee918902090ade827643e758e98363278815de583c75d111fdd665ebffde9f", size = 122987706, upload-time = "2026-05-13T14:54:00.335Z" }, - { url = "https://files.pythonhosted.org/packages/9b/ad/e95e822f3538171e22640a7fbe839a1fdb666600bf6487025de2ff03b11a/torch-2.12.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:10ee1448a9f304d3b987eb4656f664ba6e4d7b410ca7a5a7c642199777a2cf88", size = 88319556, upload-time = "2026-05-13T14:54:05.574Z" }, - { url = "https://files.pythonhosted.org/packages/b7/07/055d06d985b445d67422d25b033c11cf55bbb81785d4c4e68e28bca5820e/torch-2.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:af68dbf403439cae9ceaeaaf92f8352b460787dcd27b92aa05c40dd4a19c0f1e", size = 426397656, upload-time = "2026-05-13T14:52:38.84Z" }, - { url = "https://files.pythonhosted.org/packages/43/94/b0b4fdc3014122e0a7302fb90086d352aa48f2576f0b252561ebb38c01a8/torch-2.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:a6a2eebb237d3b1d9ad3b378e86d9b9e0782afdea8b1e0eba6a13646b9b49c07", size = 532183124, upload-time = "2026-05-13T14:53:16.178Z" }, - { url = "https://files.pythonhosted.org/packages/d8/c8/052405e6ad05d3237bfe5a4df78f917773956f8e17813a2d44c059068b74/torch-2.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2140e373e9a51a3e22ef62e8d14366d0b470d18f0adf19fdc757368077133a34", size = 123232462, upload-time = "2026-05-13T14:52:27.26Z" }, - { url = "https://files.pythonhosted.org/packages/67/dc/ac069f8d6e8be701535921141055293b0d4819d3d7f224a4612cf157c7f9/torch-2.12.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f7dfae4a519197dfa050e98d8e36378a0fb5899625a875c2b54445005a2e404e", size = 88027282, upload-time = "2026-05-13T14:53:05.258Z" }, - { url = "https://files.pythonhosted.org/packages/33/c3/1c1eb00e34555b536dddf792676026a988d710ed36981aa00499b36b0620/torch-2.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:891c769072637c74e9a5a77a3bc782894696d8ffec83b938df8536dee7f0ba78", size = 426386961, upload-time = "2026-05-13T14:51:28.406Z" }, - { url = "https://files.pythonhosted.org/packages/cd/d4/7e730dba0c7032a4154dc9056b76cf9625515e030e269cfbf8098fcfee7d/torch-2.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e2ad3eb85d39c3cab62dfa93ed5a73516e6a53c6713cb97d004004fe089f0f1f", size = 532272265, upload-time = "2026-05-13T14:51:59.308Z" }, - { url = "https://files.pythonhosted.org/packages/f1/b4/92c80d1bbfee1c0036c06d1d2155a3065bd2423134c83bf8a47e65cd6b9b/torch-2.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:c66696857e987efb8bc1777a37357ec4f60ab5e8af6250b83d6034437fa2d8f3", size = 122987138, upload-time = "2026-05-13T14:51:45.942Z" }, - { url = "https://files.pythonhosted.org/packages/7b/78/2e12b37ce50a19a037d7bc62d652a5a8f27385a7b05859d6bc9204f20cfe/torch-2.12.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:b4556715c8572758625d62b6e0ae3b1f76c440221913a6fb5e100f321fb4fb02", size = 88320100, upload-time = "2026-05-13T14:51:39.955Z" }, - { url = "https://files.pythonhosted.org/packages/56/5e/83c450ec7b0bb40a7b74611c1b5440f9260e33c54c90d556fd4a1f0fd955/torch-2.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a43ac605a5e13116c72b64c359644cce0229f213dde48d2ae0ae5eb5becf7feb", size = 426391871, upload-time = "2026-05-13T14:52:14.989Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e9/1a0b575d98d0afedd8f157d23fa3d2759421483660448e60d0a4b10b6daa/torch-2.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6a7512adfdd7f6732e40de1c620831e3c75b39b98cef60b11d0c5f0a76473ec5", size = 532192241, upload-time = "2026-05-13T14:51:07.795Z" }, - { url = "https://files.pythonhosted.org/packages/88/21/afadd25ecd81b3cea1e11c73cf1ab41a983a50271548c3ec7ec3b9efc3e9/torch-2.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5f96b63f8287f66a005dd1b5a6abba2920f11156c5e5c4d815f3e2050fd1aa16", size = 123231092, upload-time = "2026-05-13T14:51:18.854Z" }, -] - -[[package]] -name = "tqdm" -version = "4.68.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/85/05/0d5260f1f1ca784f4a4a0def9cbe6affe587f5b4025328d446c3d67765f4/tqdm-4.68.2.tar.gz", hash = "sha256:89c230e8dbc67c7615c142487111222f878c77427ea09549960f62389e258add", size = 171923, upload-time = "2026-06-09T13:26:42.539Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/75/1a0392bcc21c44dcdf87b3cf2d137e7829be2c083a1e38d44efca3d57a16/tqdm-4.68.2-py3-none-any.whl", hash = "sha256:d4240441fb5353290b87d6a85968c9decc131a99b8c7faa28269d829de669ede", size = 78578, upload-time = "2026-06-09T13:26:40.731Z" }, -] - -[[package]] -name = "transformers" -version = "5.10.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "huggingface-hub" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "packaging" }, - { name = "pyyaml" }, - { name = "regex" }, - { name = "safetensors" }, - { name = "tokenizers" }, - { name = "tqdm" }, - { name = "typer" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8d/38/d5f978bd5091019e89aef29b9a831f5cd70f2598963a3ead8b9570cab592/transformers-5.10.2.tar.gz", hash = "sha256:f9a44b9c8ca9ab1156b467f574d832ea066284299c2fd0ed84641ccb592751fc", size = 8799687, upload-time = "2026-06-04T18:43:49.119Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/73/6f/e1564b0cc182afa05e219a8e09a8e770ffaab879b6b824b56c819bd221da/transformers-5.10.2-py3-none-any.whl", hash = "sha256:8a669db546f82c7c3618cb46ceb0f0afd89292bc70f319c058f8332ec63e268d", size = 11003830, upload-time = "2026-06-04T18:43:45.303Z" }, -] - -[[package]] -name = "triton" -version = "3.7.0" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/97/dcd1f2a0f8336691bff74abc59b2ed9c69a0c0f8f65cd77109c49e05f068/triton-3.7.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223ac302091491436c248a34ee1e6c47a1026486579103c906ffd805be50cb89", size = 188367104, upload-time = "2026-05-07T19:04:56.68Z" }, - { url = "https://files.pythonhosted.org/packages/b2/c0/c2ac4fd2d8809b7579d4a820a0f9e5de62a9bc8a757ed4b3abf4f7ee964a/triton-3.7.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c631b65668d4951213b948a413c0564184305b77bb45cc9d686d3e1ecc4701a3", size = 201313191, upload-time = "2026-05-07T18:45:58.444Z" }, - { url = "https://files.pythonhosted.org/packages/b8/c1/5d842314bb6c78442cc60437928781701c6050b8d479bc2a1aed691d37ca/triton-3.7.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9e71fc392675fac364e0ecf4ef3f76f85b7f5433a16f4c3c5fe5f05a52c85fe", size = 188480277, upload-time = "2026-05-07T19:05:03.231Z" }, - { url = "https://files.pythonhosted.org/packages/13/31/8315ea5f8dd18e60970b3022e3a8b93fd37e0b784fbbef86e10c8e6e5ca1/triton-3.7.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22bacffce443f54593dd20f05294d5a40622e0ea9ab632816f87154504356221", size = 201415942, upload-time = "2026-05-07T18:46:06.479Z" }, - { url = "https://files.pythonhosted.org/packages/f7/13/ec05adfcd87311d532ba61e3af143e8be59fcd26675884c4682841406a20/triton-3.7.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4bf49b00a7a377a68a6da603a876e797614e6455a80e9021669c476a953ad9a", size = 188505104, upload-time = "2026-05-07T19:05:09.843Z" }, - { url = "https://files.pythonhosted.org/packages/62/7b/468a576e35beef1426e0828e28e9ba9e65f5474d496f16ee126c15646324/triton-3.7.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f111161d49bf903c0eaedde3962353a3d841c08a836839b7cc1025b8426efcf", size = 201457567, upload-time = "2026-05-07T18:46:13.505Z" }, - { url = "https://files.pythonhosted.org/packages/01/e1/a59a583de59b8f62c495d67c80ee3ea97d09e91ac80c4c6e76456ed8d8ac/triton-3.7.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abdf6beaa89b1bcfb9a43cd990536ce66091a997841a4814b260b7bee4c88c3c", size = 188503209, upload-time = "2026-05-07T19:05:17.935Z" }, - { url = "https://files.pythonhosted.org/packages/30/b1/b7507bb9815d403927c8dd51d4158ed2e11751a92dbc118a044f247b6848/triton-3.7.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a35d7afe3f3f058e7ec49fcce09794049e0ffc5c59019ac25ec3413741b8c4e7", size = 201453566, upload-time = "2026-05-07T18:46:20.427Z" }, - { url = "https://files.pythonhosted.org/packages/a6/8f/0bea7a6a0c989315c9135a1d7fb37e41905cfb3a17cbc1f10044ebd4cc3a/triton-3.7.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc1d61c172d257db80ddf42595131fb196ad2e9bdd751e90fe2ef13531734e8b", size = 188612899, upload-time = "2026-05-07T19:05:24.955Z" }, - { url = "https://files.pythonhosted.org/packages/e1/02/d96f57828d0912aec733b9bc7e0e7dbfd2c6f079a8fa433ac25cb93d1a30/triton-3.7.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70fb9bbdc9f400afc54bbf6eb2670af28829a6ae3996863317964783141daf56", size = 201553816, upload-time = "2026-05-07T18:46:27.49Z" }, - { url = "https://files.pythonhosted.org/packages/40/fb/82a802dac4689f2a2fb2e69302e6a138eecc3e175bbe976ba3cfc717683a/triton-3.7.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a44a8476d0d3571eac4e4d1048e1ff75aad81a09ff4602ccfc56c6dea1672e", size = 188507879, upload-time = "2026-05-07T19:05:32.209Z" }, - { url = "https://files.pythonhosted.org/packages/8f/af/9904ec6d3c93d9b24e5ec360445bbdf758b7f00bfbeedb89cb0eb64eb8bb/triton-3.7.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9b85e72968a9d8bba5ddb24e9b64aaabaf48affb042f2755cb7cfa92b7531ce", size = 201460637, upload-time = "2026-05-07T18:46:34.749Z" }, - { url = "https://files.pythonhosted.org/packages/a1/f9/4835a8ea746b88727d8899f4e3ccce4f9cacb38abfc3bb0a638266c53111/triton-3.7.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18a160de426fd99f92b0baf509045360afbd3bfaa0b4a5171dde800ec9f09684", size = 188608706, upload-time = "2026-05-07T19:05:39.218Z" }, - { url = "https://files.pythonhosted.org/packages/c1/68/fa86e5a39608000f645535b2c124920126327ab731f8c4fafd5b07ff8d4b/triton-3.7.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce061073102714b725f3660ec6939d94a1da7984b3aa99c921417cae273672f5", size = 201546766, upload-time = "2026-05-07T18:46:42.088Z" }, -] - -[[package]] -name = "typer" -version = "0.26.7" -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/5e/ed/ef06584ccdd5c410df0837951ecd7e15d9a6144ea1bd4c73cecab1a89891/typer-0.26.7.tar.gz", hash = "sha256:e314a34c617e419c091b2830dda3ea1f257134ff593061a8f5b9717ab8dddb3a", size = 201709, upload-time = "2026-06-03T07:18:06.843Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/24/25/2201973529af2c954de0bb725323c3aaed6d7f0ceee8f550dec9185df013/typer-0.26.7-py3-none-any.whl", hash = "sha256:5c87cfbc5d34491c5346ebf49c23e18d56ccb863268d3a8d592b26087c2f5e58", size = 122456, upload-time = "2026-06-03T07:18:05.732Z" }, -] - [[package]] name = "types-pyyaml" version = "6.0.12.20260518" @@ -6183,15 +3691,6 @@ 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 = "uc-micro-py" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, -] - [[package]] name = "uritemplate" version = "4.2.0" @@ -6323,32 +3822,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/e7/3937b9a9d6745b94dbe7b86531e098db8c53b77c8d07df7daa9577a47b8e/uuid_utils-0.16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:680799a9ade01d69c53cb9d41392ced24919d4f600bfab5060b61fca37510097", size = 178508, upload-time = "2026-05-19T07:43:43.774Z" }, ] -[[package]] -name = "uv" -version = "0.11.19" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/67/f0/6254502aebfdc0a9df6069269a126dd58252ac29d2d6cdf4777cea3e90b5/uv-0.11.19.tar.gz", hash = "sha256:f56f5bf853626a30423052d7ee00bf5cc940a08347d6ee7ede96862d084054a5", size = 4213580, upload-time = "2026-06-03T22:37:15.976Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/73/be32c2f6ba30fa9d8b3baceb478107cc23722d4aaab87145a332e4985185/uv-0.11.19-py3-none-linux_armv6l.whl", hash = "sha256:c729f56ffef9b945053412c839695e8a0b13758aa15b7763e95a7dd539a6f522", size = 23620003, upload-time = "2026-06-03T22:37:53.017Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ed/3aefe4a4ca4ac9204c6745670dbe12f4add69194d40f5abd1c7bd45ba9af/uv-0.11.19-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a98495b9dd67287d8c1a0786f98cb037a50f0ee6c3d648572edaa7137aabc277", size = 23183211, upload-time = "2026-06-03T22:37:20.699Z" }, - { url = "https://files.pythonhosted.org/packages/5b/eb/5d1469f9e709d56066f292978711fbf1f805b7fb46f901d3c1f260fd9908/uv-0.11.19-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fdd881cd6d80782afcf8c1d446dd15a42985167fd812b763d38ba1e4a8d944d", size = 21754003, upload-time = "2026-06-03T22:37:05.027Z" }, - { url = "https://files.pythonhosted.org/packages/7b/93/109b5ee6678f54492f94fdef74149643eaa1f2f4716906a2a10816b31247/uv-0.11.19-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:7222f45b5541551057bfc2e3021f113800704f665c119fdf3ea700c6c4859b21", size = 23518832, upload-time = "2026-06-03T22:37:28.794Z" }, - { url = "https://files.pythonhosted.org/packages/08/0c/8c59bbcf78e94ca9994256920efa99d1c4dc9d0b966eb62ebba075585a16/uv-0.11.19-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:2e0e0b8ad59ec56f1440d6e4313b64a1d8119275dcec73d19eef33c43f99428c", size = 23163128, upload-time = "2026-06-03T22:37:23.226Z" }, - { url = "https://files.pythonhosted.org/packages/89/d6/69caf9e6f11c84b5fb92df190b46fbecb7dc6645ae891c6ed66d7aaaa310/uv-0.11.19-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4aa17ffd719daf37b7a6265efd3ee4922a8ddaabaf0406d2b28c7e5ce2f20ff", size = 23164395, upload-time = "2026-06-03T22:37:18.11Z" }, - { url = "https://files.pythonhosted.org/packages/d6/83/0c2242b77c51ac33a0ddd8b06790429a0b8b9623974c9594ab2b0070ec47/uv-0.11.19-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32d7988c0dfb6f90941f201c871a4478e96e4f2a32bdb2256d62a78ee20593fc", size = 24541708, upload-time = "2026-06-03T22:37:08.093Z" }, - { url = "https://files.pythonhosted.org/packages/54/10/b1404fc52c0eddc3655f57a8b76e79dcf8dd02568382272f17e2fa68c4bb/uv-0.11.19-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2d663bacb97e2e8412d1c26eace28c7ebbde9d6f5d7d78760fafd114d693817f", size = 25575501, upload-time = "2026-06-03T22:37:47.526Z" }, - { url = "https://files.pythonhosted.org/packages/7c/17/4cda5994195ba9ce1f6971d40d5f2ceec58e2a79030d9052b3bf322557b1/uv-0.11.19-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:574f5dd4f31666661ea6386d3b91c5f0e8b84a8cae98ebba447c4674f2e6a4c7", size = 24827200, upload-time = "2026-06-03T22:37:34.039Z" }, - { url = "https://files.pythonhosted.org/packages/5a/74/2bd8b51e1d76210fd424ae55ec3f34ded5a10eeff3dd38aeb03c816a0af2/uv-0.11.19-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:731d9fab8db5d41590af64236d03f8069c8da665fd0f9493b85985f19c86cd90", size = 24872664, upload-time = "2026-06-03T22:37:11.301Z" }, - { url = "https://files.pythonhosted.org/packages/06/b1/44b0764f656bbdd0728118610a63f2feddd9cbe450f974d80c5bb56aad34/uv-0.11.19-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:301fd78309fc545c2cec2bfcc61a6bbdde876856c6d2041502737cf44085c178", size = 23617890, upload-time = "2026-06-03T22:37:44.796Z" }, - { url = "https://files.pythonhosted.org/packages/d2/25/312fa33cd4c34e7618f86cad0c9fdb312d8fef2e7fc61944c1a2f1bf1256/uv-0.11.19-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:62b0b35a51d3034ff30ecd0f381e9bbc20d5b335754f54b098da29424d551ceb", size = 24267220, upload-time = "2026-06-03T22:37:39.425Z" }, - { url = "https://files.pythonhosted.org/packages/8d/25/13856aeff9e14c98ee3e1ceae4d209301cbdeabde93abcd758433601dc82/uv-0.11.19-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:65e932720daed1af1f720a0ff5f9b33ee5f7ad97488dcceceb85154fc1323b82", size = 24376177, upload-time = "2026-06-03T22:37:50.276Z" }, - { url = "https://files.pythonhosted.org/packages/45/7d/590b3ab420e03504cf658d2981e1fcb4af60f3858d42da1d4d8740141dd9/uv-0.11.19-py3-none-musllinux_1_1_i686.whl", hash = "sha256:8f90b6687a480d154595aa619fb836a9a20d00ce37293db8099aad924f2b18f9", size = 23808336, upload-time = "2026-06-03T22:37:26.086Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8e/40acebd4ea419c870930580623e8367e23d810a0ecb8cc2f44d852a27293/uv-0.11.19-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:28b0d612a766eb25756dbaa315433b726e93affa467d29a2682cc317547952ba", size = 25080747, upload-time = "2026-06-03T22:37:13.886Z" }, - { url = "https://files.pythonhosted.org/packages/9c/d3/4037b2acb2bb73b1a3ee47a1d23864ecc503f5840387afd29f621d4fd2ec/uv-0.11.19-py3-none-win32.whl", hash = "sha256:aa6a7e8d07b33ad22f4732848ebb1d9486503973c248d6e632c06ce4339fe347", size = 22459533, upload-time = "2026-06-03T22:37:36.741Z" }, - { url = "https://files.pythonhosted.org/packages/d4/43/f374fad7ad94e4a8c47cf09f00d803c76c6cc7f225668c41f4e2fb5de000/uv-0.11.19-py3-none-win_amd64.whl", hash = "sha256:480fc34a8d0967af6a90b3f99a6e5687cd5c6e29528de96bec04d6e305a59363", size = 25143888, upload-time = "2026-06-03T22:37:42.169Z" }, - { url = "https://files.pythonhosted.org/packages/18/98/d2db53ae036528b0a9407529ef175ee200b01f626c9c160978784c8af870/uv-0.11.19-py3-none-win_arm64.whl", hash = "sha256:50e4d4796ca1a6da359a4f723a0fea86640c381d3ff4fa759a41badd7cb52dee", size = 23601290, upload-time = "2026-06-03T22:37:31.393Z" }, -] - [[package]] name = "uvicorn" version = "0.49.0" @@ -6363,61 +3836,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, ] -[package.optional-dependencies] -standard = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "httptools" }, - { name = "python-dotenv" }, - { name = "pyyaml" }, - { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, - { name = "watchfiles" }, - { name = "websockets" }, -] - -[[package]] -name = "uvloop" -version = "0.22.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/14/ecceb239b65adaaf7fde510aa8bd534075695d1e5f8dadfa32b5723d9cfb/uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", size = 1343335, upload-time = "2025-10-16T22:16:11.43Z" }, - { url = "https://files.pythonhosted.org/packages/ba/ae/6f6f9af7f590b319c94532b9567409ba11f4fa71af1148cab1bf48a07048/uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792", size = 742903, upload-time = "2025-10-16T22:16:12.979Z" }, - { url = "https://files.pythonhosted.org/packages/09/bd/3667151ad0702282a1f4d5d29288fce8a13c8b6858bf0978c219cd52b231/uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86", size = 3648499, upload-time = "2025-10-16T22:16:14.451Z" }, - { url = "https://files.pythonhosted.org/packages/b3/f6/21657bb3beb5f8c57ce8be3b83f653dd7933c2fd00545ed1b092d464799a/uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd", size = 3700133, upload-time = "2025-10-16T22:16:16.272Z" }, - { url = "https://files.pythonhosted.org/packages/09/e0/604f61d004ded805f24974c87ddd8374ef675644f476f01f1df90e4cdf72/uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2", size = 3512681, upload-time = "2025-10-16T22:16:18.07Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ce/8491fd370b0230deb5eac69c7aae35b3be527e25a911c0acdffb922dc1cd/uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec", size = 3615261, upload-time = "2025-10-16T22:16:19.596Z" }, - { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, - { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, - { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, - { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, - { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, - { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, - { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, - { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, - { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, - { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, - { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, - { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, - { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, - { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, - { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, - { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, - { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, - { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, - { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, - { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, - { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, - { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, - { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, - { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, - { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, - { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, -] - [[package]] name = "validators" version = "0.35.0" @@ -6459,132 +3877,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ] -[[package]] -name = "watchfiles" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/5a/2bf22ecb24916983bf1cc0095e7dea2741d14d6553b0d6a2ac8bc96eca93/watchfiles-1.2.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:bb68bf4df85abebe5efddc53cf2075520f243a59868d9b3973278b23e76962a9", size = 400471, upload-time = "2026-05-18T04:31:08.908Z" }, - { url = "https://files.pythonhosted.org/packages/55/70/dea1f6a0e76607841a60fb51af150e70124864673f61704abb62b90cdcc7/watchfiles-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c16cb06dd17d43b9d185094268459eac92c9538356f050e55b54e82cf700e1d4", size = 394599, upload-time = "2026-05-18T04:30:19.845Z" }, - { url = "https://files.pythonhosted.org/packages/18/52/752dcc7dc817baef5e89518732925795ce52e36a683a9a3c9fb68b21504e/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a0feab9af4c021c581f695258c642b3d10c5fd4c676e33a0d8606425d82631", size = 455458, upload-time = "2026-05-18T04:30:29.126Z" }, - { url = "https://files.pythonhosted.org/packages/12/48/366ebbb22fcc504c2f72b45f0b7e72f40a18795cc01752c16066d597b67a/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a16ffe19bf5cf9f5edaa1ad1dd830c5a816e8feec430c522302ab55483a4b994", size = 460513, upload-time = "2026-05-18T04:31:40.85Z" }, - { url = "https://files.pythonhosted.org/packages/ad/44/1f9e1b15e7a729062e0d0c3d0d7225ea4ab98b2267ef87287153be2495fc/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204f299afcbd65918ab78dbc52626b0ae45e9d8cef403fdbf33ecf9e40eac66e", size = 493616, upload-time = "2026-05-18T04:30:58.47Z" }, - { url = "https://files.pythonhosted.org/packages/7e/55/8b1086dcc8a1d6a697a62767bd7ea368e74c61c6fd171683cfe24a3fe5d2/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:11743adfa510bfffebe97659fb280182b5c9b238708f667e866f308c3430dc19", size = 573154, upload-time = "2026-05-18T04:30:37.903Z" }, - { url = "https://files.pythonhosted.org/packages/14/7a/242f400cc77fafa7b18d53d19d9cb64fc6a6f61f28c55913bae7c674d92a/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb72919d93e3a16fc451d3aa3d4b1698423daca1b382d3d959c9ac51297c12a8", size = 467046, upload-time = "2026-05-18T04:30:41.869Z" }, - { url = "https://files.pythonhosted.org/packages/02/c8/79eee650c62d2c186598489814468e389b5def0ebe755399ff645b35b1b2/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62f042afde2dde21ec1d2c1a74361e804673df86f51e418a999c9acfe671b07", size = 457100, upload-time = "2026-05-18T04:31:13.064Z" }, - { url = "https://files.pythonhosted.org/packages/81/36/519f6dbb7a95e4fe7c1513ed25b1520295ef9905a27f1f2226a73892bfb7/watchfiles-1.2.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:027ae72bfdfd254862065d8b3e2a815c6ab9b1853ce41e6648ece84afd34a551", size = 467038, upload-time = "2026-05-18T04:30:32.915Z" }, - { url = "https://files.pythonhosted.org/packages/2f/12/951af6b9f89097e02511122258402cb3578443021930b70cf968d6310dc0/watchfiles-1.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e1cfd51e97e13ff3bd047c140764d277fc9b95b7cb5da59e46a47d167adab310", size = 632563, upload-time = "2026-05-18T04:30:11.539Z" }, - { url = "https://files.pythonhosted.org/packages/28/cc/0cba1f0a6117b7ec117271bdc3cb3a5a252005959755a2c09a745e0942cc/watchfiles-1.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:24b2405c0a46738dd9e1cf7135aa5dbdb9d42d024628651b3b13d5117e99f8df", size = 660851, upload-time = "2026-05-18T04:31:53.186Z" }, - { url = "https://files.pythonhosted.org/packages/d0/f2/26347558cc8bf6877845e66b315f644d03c173906aa09e233a3f4fd23928/watchfiles-1.2.0-cp310-cp310-win32.whl", hash = "sha256:8c520725602756229f045b032a1ff33d7ef0f7404189d62f6c2438cb6d8ef6a1", size = 277023, upload-time = "2026-05-18T04:30:18.825Z" }, - { url = "https://files.pythonhosted.org/packages/6d/68/a5e67b6b68e94f4c1511d61c46c55eba0737583620b6febf194c7b9cc23f/watchfiles-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:03b14855c6f35539e2d95c442ae9530a75762f1e26567152b9ed05f96534a74d", size = 290107, upload-time = "2026-05-18T04:32:09.677Z" }, - { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" }, - { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" }, - { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" }, - { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" }, - { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" }, - { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" }, - { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" }, - { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" }, - { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" }, - { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" }, - { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" }, - { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" }, - { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" }, - { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" }, - { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, - { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, - { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, - { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, - { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, - { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, - { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, - { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, - { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, - { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, - { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, - { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, - { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, - { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, - { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, - { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, - { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, - { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, - { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, - { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, - { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, - { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, - { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, - { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, - { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, - { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, - { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, - { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, - { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, - { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, - { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, - { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, - { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, - { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, - { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, - { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, - { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, - { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, - { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, - { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, - { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, - { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, - { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, - { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, - { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, - { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, - { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, - { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, - { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, - { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, - { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, - { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, - { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, - { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, - { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, - { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, - { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, - { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, - { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, - { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, - { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, - { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, - { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, - { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, - { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, - { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, - { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, - { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, - { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, - { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" }, - { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" }, - { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, - { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, -] - -[[package]] -name = "websocket-client" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, -] - [[package]] name = "websockets" version = "16.0" @@ -6995,19 +4287,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, ] -[[package]] -name = "youtube-transcript-api" -version = "1.2.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "defusedxml" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/60/43/4104185a2eaa839daa693b30e15c37e7e58795e8e09ec414f22b3db54bec/youtube_transcript_api-1.2.4.tar.gz", hash = "sha256:b72d0e96a335df599d67cee51d49e143cff4f45b84bcafc202ff51291603ddcd", size = 469839, upload-time = "2026-01-29T09:09:17.088Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/be/95/129ea37efd6cd6ed00f62baae6543345c677810b8a3bf0026756e1d3cf3c/youtube_transcript_api-1.2.4-py3-none-any.whl", hash = "sha256:03878759356da5caf5edac77431780b91448fb3d8c21d4496015bdc8a7bc43ff", size = 485227, upload-time = "2026-01-29T09:09:15.427Z" }, -] - [[package]] name = "zipp" version = "4.1.0"