diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 75a0e8ff..c782cd93 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,10 +1,39 @@ -Fixes # + + -## One line description for the changelog +**Related Issue:** # +**Type of change:** + +- Bug fix (non-breaking change which fixes an issue) +- New feature (non-breaking change which adds functionality) +- Breaking change (fix or feature that would cause existing functionality to not work as expected) +- This change requires a documentation update +- Other (please describe): -- [ ] Tests pass -- [ ] Appropriate changes to README are included in PR +**Description:** + + +--- + +**Pre-submission checklist:** +- [ ] I have read the [CONTRIBUTING.md](https://github.com/cloudevents/sdk-python/blob/main/CONTRIBUTING.md) file. +- [ ] I have signed off my commits using `git commit --signoff`. +- [ ] I have added tests that prove my fix is effective or that my feature works. +- [ ] I have updated the documentation (`README.md`, `CHANGELOG.md`, etc.) as necessary. +- [ ] I have run `pre-commit` and `tox` and all checks pass. +- [ ] This pull request is ready to be reviewed. diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 52e7c9a0..772b7930 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -3,37 +3,38 @@ name: CI on: [push, pull_request] jobs: - lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 + - uses: actions/checkout@v6 + - name: Install uv + uses: astral-sh/setup-uv@v7 with: - python-version: '3.12' - cache: 'pip' - cache-dependency-path: 'requirements/*.txt' - - name: Install dev dependencies - run: python -m pip install -r requirements/dev.txt - - name: Run linting - run: python -m tox -e lint,mypy,mypy-samples-image,mypy-samples-json + enable-cache: true + cache-dependency-glob: "uv.lock" + - name: Set up Python + run: uv python install 3.12 + - name: Install the project + run: uv sync --all-extras --dev + - name: Lint + run: uv run ruff check --select I test: strategy: matrix: - python: ['3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] os: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 + - uses: actions/checkout@v6 + - name: Install uv + uses: astral-sh/setup-uv@v7 with: - python-version: ${{ matrix.python }} - cache: 'pip' - cache-dependency-path: 'requirements/*.txt' - - name: Install dev dependencies - run: python -m pip install -r requirements/dev.txt + enable-cache: true + cache-dependency-glob: "uv.lock" + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + - name: Install the project + run: uv sync --all-extras --dev - name: Run tests - run: python -m tox -e py # Run tox using the version of Python in `PATH` + run: uv run pytest tests diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml index eeebb883..4a414248 100644 --- a/.github/workflows/pypi-release.yml +++ b/.github/workflows/pypi-release.yml @@ -10,41 +10,38 @@ on: jobs: build_dist: name: Build source distribution - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 + - name: Install uv + uses: astral-sh/setup-uv@v7 + - name: Set up Python + run: uv python install 3.12 + - name: Install the project + run: uv sync --all-extras --dev - name: Build SDist and wheel - run: pipx run build + run: uv build - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: artifact path: dist/* - name: Check metadata - run: pipx run twine check dist/* + run: uvx twine check dist/* publish: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 if: github.event_name == 'push' needs: [ build_dist ] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 - - name: Set up Python - uses: actions/setup-python@v5 + - uses: actions/download-artifact@v7 with: - python-version: "3.12" - cache: 'pip' - - name: Install build dependencies - run: pip install -U setuptools wheel build - - uses: actions/download-artifact@v4 - with: - # unpacks default artifact into dist/ - # if `name: artifact` is omitted, the action will create extra parent dir name: artifact path: dist - name: Publish @@ -52,8 +49,19 @@ jobs: with: user: __token__ password: ${{ secrets.pypi_password }} - attestations: false - - name: Install GitPython and cloudevents for pypi_packaging - run: pip install -U -r requirements/publish.txt - - name: Create Tag - run: python pypi_packaging.py + tag: + runs-on: ubuntu-24.04 + needs: [ publish ] + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Extract version + id: version + run: | + echo "version=$(grep -oP '__version__ = \"\K[^\"]+' src/cloudevents/__init__.py)" >> $GITHUB_OUTPUT + - name: Create and push tag + uses: pxpm/github-tag-action@1.0.1 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" + tag: ${{ steps.version.outputs.version }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 32fde356..0ab1da3a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,27 +1,24 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-toml - - repo: https://github.com/pycqa/isort - rev: 6.0.1 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.7 hooks: - - id: isort - args: [ "--profile", "black", "--filter-files" ] - - repo: https://github.com/psf/black - rev: 25.1.0 - hooks: - - id: black - language_version: python3.11 + # Run the linter. + - id: ruff + # Run the formatter. + - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.16.0 + rev: v1.19.1 hooks: - id: mypy - files: ^(cloudevents/) - exclude: ^(cloudevents/tests/) - types: [ python ] - args: [ ] + files: ^(src/cloudevents/|tests/) + exclude: ^(src/cloudevents/v1/|tests/test_v1_compat/) + types: [python] + args: ["--config-file=pyproject.toml"] additional_dependencies: - - "pydantic~=2.7" + - types-python-dateutil>=2.9.0.20260305 diff --git a/CHANGELOG.md b/CHANGELOG.md index ae519e71..3875afe5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.0.0] + +### Changed + +- Released stable v2.0.0. See [migration](./MIGRATION.md) for details and migration + guidelines. ([#279]) + +## [2.0.0.alpha6] + +### Changed + +- Updated tooling and workflows. ([#278]) + +### Fixed + +- Fixed the v1 compatibility layer. ([#276]) + +## [2.0.0.alpha5] + +### Changed + +- Improved spec compatibility of attributes processing. ([#275]) + +## [2.0.0.alpha4] + +### Changed + +- CloudEvents v2 is ready to become the main version. ([#273]) + ## [1.12.1] ### Changed - CloudEvents v1 moved to security fixes support stage. -CloudEvents v2 is a rewrite with ongoing development ([]) +CloudEvents v2 is a rewrite with ongoing development ([#271]) ## [1.12.0] @@ -309,3 +338,9 @@ CloudEvents v2 is a rewrite with ongoing development ([]) [#240]: https://github.com/cloudevents/sdk-python/pull/240 [#248]: https://github.com/cloudevents/sdk-python/pull/248 [#249]: https://github.com/cloudevents/sdk-python/pull/249 +[#271]: https://github.com/cloudevents/sdk-python/pull/271 +[#273]: https://github.com/cloudevents/sdk-python/pull/273 +[#275]: https://github.com/cloudevents/sdk-python/pull/275 +[#276]: https://github.com/cloudevents/sdk-python/pull/276 +[#278]: https://github.com/cloudevents/sdk-python/pull/278 +[#279]: https://github.com/cloudevents/sdk-python/pull/279 diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 00000000..e161931c --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,422 @@ +# Migrating from CloudEvents SDK v1 to v2 + +This guide covers the breaking changes and new patterns introduced in v2 of the +CloudEvents Python SDK. + +## Requirements + +| | v1 | v2 | +|--------------|------------------------------------|-------------------------------| +| Python | 3.7+ | **3.10+** | +| Dependencies | varies (optional `pydantic` extra) | `python-dateutil>=2.8.2` only | + +## Intermediate Step: `cloudevents.v1` Compatibility Layer + +If you are not ready to migrate to the v2 core API, the `cloudevents.v1` package +provides a drop-in compatibility layer that preserves the v1 API under a new namespace. +This lets you unpin from the old top-level imports without rewriting your event-handling +logic. + +Swap the old top-level imports for their `cloudevents.v1.*` equivalents: + +| Old import | Compat layer import | +|----------------------------------------------------|--------------------------------------------------| +| `from cloudevents.http import CloudEvent` | `from cloudevents.v1.http import CloudEvent` | +| `from cloudevents.http import from_http` | `from cloudevents.v1.http import from_http` | +| `from cloudevents.http import from_json` | `from cloudevents.v1.http import from_json` | +| `from cloudevents.http import from_dict` | `from cloudevents.v1.http import from_dict` | +| `from cloudevents.conversion import to_binary` | `from cloudevents.v1.http import to_binary` | +| `from cloudevents.conversion import to_structured` | `from cloudevents.v1.http import to_structured` | +| `from cloudevents.conversion import to_json` | `from cloudevents.v1.http import to_json` | +| `from cloudevents.conversion import to_dict` | `from cloudevents.v1.conversion import to_dict` | +| `from cloudevents.kafka import KafkaMessage` | `from cloudevents.v1.kafka import KafkaMessage` | +| `from cloudevents.kafka import to_binary` | `from cloudevents.v1.kafka import to_binary` | +| `from cloudevents.kafka import from_binary` | `from cloudevents.v1.kafka import from_binary` | +| `from cloudevents.pydantic import CloudEvent` | `from cloudevents.v1.pydantic import CloudEvent` | + +The compat layer behaviour is identical to the old v1 SDK: events are dict-like and +mutable, marshallers/unmarshallers are accepted as callables, and `is_binary`/ +`is_structured` helpers are still available. The compat layer does **not** enforce +strict mypy and is not under the v2 validation rules. + +When you are ready to move fully to v2, follow the rest of this guide. + +## Architectural Changes + +v2 is a ground-up rewrite with four fundamental shifts: + +1. **Protocol-based design** -- `BaseCloudEvent` is a `Protocol`, not a base class. + Events expose explicit getter methods instead of dict-like access. +2. **Explicit serialization** -- Implicit JSON handling with marshaller callbacks is + replaced by a `Format` protocol. `JSONFormat` is the built-in implementation; you can + write your own. +3. **Same auto-generated attributes** -- Like v1, v2 auto-generates `id` (UUID4), + `time` (UTC now), and `specversion` (`"1.0"` or `"0.3"`) if omitted. Only `type` and + `source` are strictly required. +4. **Strict validation** -- Events are validated at construction time. Extension + attribute names must be 1-20 lowercase alphanumeric characters. `time` must be a + timezone-aware `datetime`. + +## Creating Events + +**v1:** + +```python +from cloudevents.http import CloudEvent + +# id, specversion, and time are auto-generated +event = CloudEvent( + {"type": "com.example.test", "source": "/myapp"}, + data={"message": "Hello"}, +) +``` + +**v2:** + +```python +from cloudevents.core.v1.event import CloudEvent + +# id, specversion, and time are auto-generated (just like v1) +event = CloudEvent( + attributes={"type": "com.example.test", "source": "/myapp"}, + data={"message": "Hello"}, +) +``` + +## Accessing Event Attributes + +v1 events were dict-like. v2 events use explicit getter methods and are immutable after +construction. + +**v1:** + +```python +# Dict-like access +source = event["source"] +event["subject"] = "my-subject" +del event["subject"] + +# Iteration +for attr_name in event: + print(attr_name, event[attr_name]) + +# Membership test +if "subject" in event: + pass +``` + +**v2:** + +```python +# Explicit getters for required attributes +source = event.get_source() +event_type = event.get_type() +event_id = event.get_id() +specversion = event.get_specversion() + +# Explicit getters for optional attributes (return None if absent) +subject = event.get_subject() +time = event.get_time() +datacontenttype = event.get_datacontenttype() +dataschema = event.get_dataschema() + +# Extension attributes +custom_value = event.get_extension("myextension") + +# All attributes as a dict +attrs = event.get_attributes() + +# Data +data = event.get_data() +``` + +## HTTP Binding + +### Serializing Events + +**v1:** + +```python +from cloudevents.conversion import to_binary, to_structured + +# Returns a (headers, body) tuple +headers, body = to_binary(event) +headers, body = to_structured(event) +``` + +**v2:** + +```python +from cloudevents.core.bindings.http import to_binary_event, to_structured_event + +# Returns an HTTPMessage dataclass with .headers and .body +message = to_binary_event(event) +message = to_structured_event(event) + +# Use in HTTP requests +requests.post(url, headers=message.headers, data=message.body) +``` + +If you need to pass a custom `Format`, use the lower-level functions: + +```python +from cloudevents.core.bindings.http import to_binary, to_structured +from cloudevents.core.formats.json import JSONFormat + +message = to_binary(event, event_format=JSONFormat()) +message = to_structured(event, event_format=JSONFormat()) +``` + +### Deserializing Events + +**v1:** + +```python +from cloudevents.http import from_http + +# Auto-detects binary vs structured from headers +event = from_http(headers, body) +``` + +**v2:** + +```python +from cloudevents.core.bindings.http import from_http_event, HTTPMessage + +# Wrap raw headers/body into an HTTPMessage first +message = HTTPMessage(headers=headers, body=body) + +# Auto-detects binary vs structured and spec version (v1.0 / v0.3) +event = from_http_event(message) +``` + +Or explicitly choose the content mode: + +```python +from cloudevents.core.bindings.http import from_binary_event, from_structured_event + +event = from_binary_event(message) +event = from_structured_event(message) +``` + +## Kafka Binding + +### Serializing + +**v1:** + +```python +from cloudevents.kafka import to_binary, KafkaMessage + +kafka_msg = to_binary(event) +# kafka_msg is a NamedTuple: .headers, .key, .value +``` + +**v2:** + +```python +from cloudevents.core.bindings.kafka import to_binary_event, KafkaMessage + +kafka_msg = to_binary_event(event) +# kafka_msg is a frozen dataclass: .headers, .key, .value + +# Custom key mapping +kafka_msg = to_binary_event( + event, + key_mapper=lambda e: e.get_extension("partitionkey"), +) +``` + +### Deserializing + +**v1:** + +```python +from cloudevents.kafka import from_binary, KafkaMessage + +msg = KafkaMessage(headers=headers, key=key, value=value) +event = from_binary(msg) +``` + +**v2:** + +```python +from cloudevents.core.bindings.kafka import from_kafka_event, KafkaMessage + +msg = KafkaMessage(headers=headers, key=key, value=value) + +# Auto-detects binary vs structured and spec version +event = from_kafka_event(msg) +``` + +## AMQP Binding (New in v2) + +v2 adds native AMQP 1.0 protocol binding support. + +```python +from cloudevents.core.v1.event import CloudEvent +from cloudevents.core.bindings.amqp import ( + AMQPMessage, + to_binary_event, + from_amqp_event, +) + +# Serialize: attributes go to application_properties with cloudEvents_ prefix +amqp_msg = to_binary_event(event) +# amqp_msg.properties - AMQP message properties (e.g. content-type) +# amqp_msg.application_properties - CloudEvent attributes +# amqp_msg.application_data - event data as bytes + +# Deserialize: auto-detects binary vs structured +event = from_amqp_event(amqp_msg) +``` + +## Custom Serialization Formats + +**v1** used marshaller/unmarshaller callbacks: + +```python +# v1: pass callbacks directly +headers, body = to_binary(event, data_marshaller=yaml.dump) +event = from_http(headers, body, data_unmarshaller=yaml.safe_load) +``` + +**v2** uses the `Format` protocol. Implement it to support non-JSON formats: + +```python +from cloudevents.core.formats.base import Format +from cloudevents.core.base import BaseCloudEvent, EventFactory + + +class YAMLFormat: + """Example custom format -- implement the Format protocol.""" + + def read( + self, + event_factory: EventFactory | None, + data: str | bytes, + ) -> BaseCloudEvent: + ... # Parse YAML into attributes dict, call event_factory(attributes, data) + + def write(self, event: BaseCloudEvent) -> bytes: + ... # Serialize entire event to YAML bytes + + def write_data( + self, + data: dict | str | bytes | None, + datacontenttype: str | None, + ) -> bytes: + ... # Serialize just the data payload + + def read_data( + self, + body: bytes, + datacontenttype: str | None, + ) -> dict | str | bytes | None: + ... # Deserialize just the data payload + + def get_content_type(self) -> str: + return "application/cloudevents+yaml" +``` + +Then use it with any binding: + +```python +from cloudevents.core.bindings.http import to_binary + +message = to_binary(event, event_format=YAMLFormat()) +``` + +## Error Handling + +v2 replaces v1's exception hierarchy with more granular, typed exceptions. + +**v1:** + +```python +from cloudevents.exceptions import ( + GenericException, + MissingRequiredFields, + InvalidRequiredFields, + DataMarshallerError, + DataUnmarshallerError, +) +``` + +**v2:** + +```python +from cloudevents.core.exceptions import ( + BaseCloudEventException, # Base for all CloudEvent errors + CloudEventValidationError, # Aggregated validation errors (raised on construction) + MissingRequiredAttributeError, # Missing required attribute (also a ValueError) + InvalidAttributeTypeError, # Wrong attribute type (also a TypeError) + InvalidAttributeValueError, # Invalid attribute value (also a ValueError) + CustomExtensionAttributeError, # Invalid extension name (also a ValueError) +) +``` + +`CloudEventValidationError` contains all validation failures at once: + +```python +try: + event = CloudEvent(attributes={"source": "/test"}) # missing type +except CloudEventValidationError as e: + # e.errors is a dict[str, list[BaseCloudEventException]] + for attr_name, errors in e.errors.items(): + print(f"{attr_name}: {errors}") +``` + +## Removed Features + +| Feature | v1 | v2 Alternative | +|-----------------------------------|-----------------------------------------------|---------------------------------------------------| +| Pydantic integration | `from cloudevents.pydantic import CloudEvent` | Removed -- use the core `CloudEvent` directly | +| Dict-like event access | `event["source"]`, `event["x"] = y` | `event.get_source()`, `event.get_extension("x")` | +| `from_dict()` | `from cloudevents.http import from_dict` | Construct `CloudEvent(attributes=d)` directly | +| `to_dict()` | `from cloudevents.conversion import to_dict` | `event.get_attributes()` + `event.get_data()` | +| `from_json()` | `from cloudevents.http import from_json` | `JSONFormat().read(None, json_bytes)` | +| `to_json()` | `from cloudevents.conversion import to_json` | `JSONFormat().write(event)` | +| Custom marshallers | `data_marshaller=fn` / `data_unmarshaller=fn` | Implement the `Format` protocol | +| `is_binary()` / `is_structured()` | `from cloudevents.http import is_binary` | Mode is handled internally by `from_http_event()` | +| Deprecated helpers | `to_binary_http()`, `to_structured_http()` | `to_binary_event()`, `to_structured_event()` | + +## Quick Reference: Import Mapping + +| v1 Import | v2 Import | +|----------------------------------------|--------------------------------------------------------| +| `cloudevents.http.CloudEvent` | `cloudevents.core.v1.event.CloudEvent` | +| `cloudevents.http.from_http` | `cloudevents.core.bindings.http.from_http_event` | +| `cloudevents.http.from_json` | `cloudevents.core.formats.json.JSONFormat().read` | +| `cloudevents.http.from_dict` | `cloudevents.core.v1.event.CloudEvent(attributes=...)` | +| `cloudevents.conversion.to_binary` | `cloudevents.core.bindings.http.to_binary_event` | +| `cloudevents.conversion.to_structured` | `cloudevents.core.bindings.http.to_structured_event` | +| `cloudevents.conversion.to_json` | `cloudevents.core.formats.json.JSONFormat().write` | +| `cloudevents.conversion.to_dict` | `event.get_attributes()` | +| `cloudevents.kafka.KafkaMessage` | `cloudevents.core.bindings.kafka.KafkaMessage` | +| `cloudevents.kafka.to_binary` | `cloudevents.core.bindings.kafka.to_binary_event` | +| `cloudevents.kafka.from_binary` | `cloudevents.core.bindings.kafka.from_binary_event` | +| `cloudevents.pydantic.CloudEvent` | Removed | +| `cloudevents.abstract.AnyCloudEvent` | `cloudevents.core.base.BaseCloudEvent` | + +## CloudEvents Spec v0.3 + +Both v1 and v2 support CloudEvents spec v0.3. In v2, use the dedicated class: + +```python +from cloudevents.core.v03.event import CloudEvent + +event = CloudEvent( + attributes={ + "type": "com.example.test", + "source": "/myapp", + "id": "123", + "specversion": "0.3", + "schemaurl": "https://example.com/schema", + # v0.3-specific (renamed to dataschema in v1.0) + }, +) +``` + +Binding functions auto-detect the spec version when deserializing, so no special +handling is needed on the receiving side. diff --git a/README.md b/README.md index abcf5cbf..7d3e5fb6 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Python SDK for [CloudEvents](https://github.com/cloudevents/spec) +# Python SDK v2 for [CloudEvents](https://github.com/cloudevents/spec) [![PyPI version](https://badge.fury.io/py/cloudevents.svg)](https://badge.fury.io/py/cloudevents) @@ -14,8 +14,8 @@ This SDK current supports the following versions of CloudEvents: ## Python SDK -Package **cloudevents** provides primitives to work with CloudEvents specification: -https://github.com/cloudevents/spec. +Package [**cloudevents**](src/cloudevents) provides primitives to work with +[CloudEvents specification](https://github.com/cloudevents/spec). ### Installing @@ -33,15 +33,15 @@ Below we will provide samples on how to send cloudevents using the popular ### Binary HTTP CloudEvent ```python -from cloudevents.http import CloudEvent -from cloudevents.conversion import to_binary +from cloudevents_v1.http import CloudEvent +from cloudevents_v1.conversion import to_binary import requests # Create a CloudEvent # - The CloudEvent "id" is generated if omitted. "specversion" defaults to "1.0". attributes = { - "type": "com.example.sampletype1", - "source": "https://example.com/event-producer", + "type": "com.example.sampletype1", + "source": "https://example.com/event-producer", } data = {"message": "Hello World!"} event = CloudEvent(attributes, data) @@ -56,15 +56,15 @@ requests.post("", data=body, headers=headers) ### Structured HTTP CloudEvent ```python -from cloudevents.conversion import to_structured -from cloudevents.http import CloudEvent +from cloudevents_v1.conversion import to_structured +from cloudevents_v1.http import CloudEvent import requests # Create a CloudEvent # - The CloudEvent "id" is generated if omitted. "specversion" defaults to "1.0". attributes = { - "type": "com.example.sampletype2", - "source": "https://example.com/event-producer", + "type": "com.example.sampletype2", + "source": "https://example.com/event-producer", } data = {"message": "Hello World!"} event = CloudEvent(attributes, data) @@ -87,7 +87,7 @@ The code below shows how to consume a cloudevent using the popular python web fr ```python from flask import Flask, request -from cloudevents.http import from_http +from cloudevents_v1.http import from_http app = Flask(__name__) @@ -95,20 +95,20 @@ app = Flask(__name__) # create an endpoint at http://localhost:/3000/ @app.route("/", methods=["POST"]) def home(): - # create a CloudEvent - event = from_http(request.headers, request.get_data()) + # create a CloudEvent + event = from_http(request.headers, request.get_data()) - # you can access cloudevent fields as seen below - print( - f"Found {event['id']} from {event['source']} with type " - f"{event['type']} and specversion {event['specversion']}" - ) + # you can access cloudevent fields as seen below + print( + f"Found {event['id']} from {event['source']} with type " + f"{event['type']} and specversion {event['specversion']}" + ) - return "", 204 + return "", 204 if __name__ == "__main__": - app.run(port=3000) + app.run(port=3000) ``` You can find a complete example of turning a CloudEvent into a HTTP request @@ -162,18 +162,13 @@ with one of the project's SDKs, please send an email to ## Maintenance -We use [black][black] and [isort][isort] for autoformatting. We set up a [tox][tox] -environment to reformat the codebase. - -e.g. - -```bash -pip install tox -tox -e reformat -``` +We use [uv][uv] for dependency and package management, [ruff][ruff] and [isort][isort] +for autoformatting and [pre-commit][pre-commit] to automate those with commit +hooks. For information on releasing version bumps see [RELEASING.md](RELEASING.md) -[black]: https://black.readthedocs.io/ +[uv]: https://docs.astral.sh/uv/ +[ruff]: https://docs.astral.sh/ruff [isort]: https://pycqa.github.io/isort/ -[tox]: https://tox.wiki/ +[pre-commit]: https://pre-commit.com diff --git a/RELEASING.md b/RELEASING.md index f6ca05b1..39c621e5 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -4,22 +4,15 @@ This repository is configured to automatically publish the corresponding [PyPI package](https://pypi.org/project/cloudevents/) and GitHub Tag via GitHub Actions. To release a new CloudEvents SDK, contributors should bump `__version__` in -[cloudevents](cloudevents/__init__.py) to reflect the new release version. On merge, the action -will automatically build and release to PyPI using -[this PyPI GitHub Action](https://github.com/pypa/gh-action-pypi-publish). This -action gets called on all pushes to main (such as a version branch being merged -into main), but only releases a new version when the version number has changed. Note, -this action assumes pushes to main are version updates. Consequently, -[pypi-release.yml](.github/workflows/pypi-release.yml) will fail if you attempt to -push to main without updating `__version__` in -[cloudevents](cloudevents/__init__.py) so don't forget to do so. +`src/cloudevents/__init__.py` to reflect the new release version. On merge, the action +will automatically build and release to PyPI. This action gets called on all pushes to main +(such as a version branch being merged into main), but only releases a new version when the +version number has changed. Note, this action assumes pushes to main are version updates. +Consequently, the release workflow will fail if you attempt to push to main without updating +`__version__` in `src/cloudevents/__init__.py` so don't forget to do so. -After a version update is merged, the script [pypi_packaging.py](pypi_packaging.py) -will create a GitHub tag for the new cloudevents version using `__version__`. -The script fails if `__version__` and the local pypi version for -cloudevents are out of sync. For this reason, [pypi-release.yml](.github/workflows/pypi-release.yml) -first must upload the new cloudevents pypi package, and then download the recently updated pypi -cloudevents package for [pypi_packaging.py](pypi_packaging.py) not to fail. +After a version update is merged, a GitHub tag for the new cloudevents version is created +using `__version__`. View the GitHub workflow [pypi-release.yml](.github/workflows/pypi-release.yml) for more information. diff --git a/mypy.ini b/mypy.ini deleted file mode 100644 index 3168f2b5..00000000 --- a/mypy.ini +++ /dev/null @@ -1,16 +0,0 @@ -[mypy] -plugins = pydantic.mypy -python_version = 3.9 - -pretty = True -show_error_context = True -follow_imports_for_stubs = True -# subset of mypy --strict -# https://mypy.readthedocs.io/en/stable/config_file.html -check_untyped_defs = True -disallow_incomplete_defs = True -warn_return_any = True -strict_equality = True - -[mypy-deprecation.*] -ignore_missing_imports = True diff --git a/pypi_packaging.py b/pypi_packaging.py deleted file mode 100644 index c81986d5..00000000 --- a/pypi_packaging.py +++ /dev/null @@ -1,62 +0,0 @@ -# Copyright 2018-Present The CloudEvents Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. - -import os - -import pkg_resources - -from setup import pypi_config - - -def createTag(): - from git import Repo - - # Get local pypi cloudevents version - published_pypi_version = pkg_resources.get_distribution( - pypi_config["package_name"] - ).version - - # Ensure pypi and local package versions match - if pypi_config["version_target"] == published_pypi_version: - # Create local git tag - repo = Repo(os.getcwd()) - repo.create_tag(pypi_config["version_target"]) - - # Push git tag to remote main - origin = repo.remote() - origin.push(pypi_config["version_target"]) - - else: - # PyPI publish likely failed - print( - f"Expected {pypi_config['package_name']}=={pypi_config['version_target']} " - f"but found {pypi_config['package_name']}=={published_pypi_version}" - ) - exit(1) - - -if __name__ == "__main__": - createTag() diff --git a/pyproject.toml b/pyproject.toml index 8727d44f..49661bc3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,19 +1,155 @@ -[tool.black] +[project] +name = "cloudevents" +dynamic = ["version"] +description = "CloudEvents Python SDK" +authors = [ + { name = "The Cloud Events Contributors", email = "cncfcloudevents@gmail.com" } +] +readme = "README.md" +requires-python = ">= 3.10" +license = "Apache-2.0" +classifiers = [ + "Intended Audience :: Information Technology", + "Intended Audience :: System Administrators", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Development Status :: 5 - Production/Stable", + "Operating System :: OS Independent", + "Natural Language :: English", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", +] +keywords = [ + "CloudEvents", + "Eventing", + "Serverless", +] +dependencies = [ + "deprecation>=2.0,<3.0", + "python-dateutil>=2.8.2", +] + +[project.urls] +"Source code" = "https://github.com/cloudevents/sdk-python" +"Documentation" = "https://cloudevents.io" +"Home page" = "https://cloudevents.io" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[dependency-groups] +dev = [ + "ruff>=0.15.7", + "pytest>=9.0.2", + "mypy>=1.19.1", + "isort>=8.0.1", + "flake8>=7.3.0", + "pep8-naming>=0.15.1", + "flake8-print>=5.0.0", + "pre-commit>=4.5.1", + "pytest-cov>=7.1.0", + "types-python-dateutil>=2.9.0.20260305", + "sanic>=25.12.0", + "sanic-testing>=24.6.0", + "pydantic>=2.12.5", +] + +[tool.uv.pip] +universal = true +generate-hashes = true + +[tool.hatch.version] +path = "src/cloudevents/__init__.py" + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.build.targets.wheel.force-include] +"CHANGELOG.md" = "CHANGELOG.md" +"MAINTAINERS.md" = "MAINTAINERS.md" +"README.md" = "README.md" +"MIGRATION.md" = "MIGRATION.md" + +[tool.hatch.build.targets.sdist] +packages = ["src/cloudevents"] + +[tool.hatch.build.targets.sdist.force-include] +"CHANGELOG.md" = "CHANGELOG.md" +"MAINTAINERS.md" = "MAINTAINERS.md" +"MIGRATION.md" = "MIGRATION.md" + +[tool.ruff] line-length = 88 -include = '\.pyi?$' -exclude = ''' -/( - \.git - | \.hg - | \.mypy_cache - | \.tox - | \.venv - | _build - | buck-out - | build - | dist -)/ -''' - -[tool.isort] -profile = "black" +target-version = "py310" + +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".mypy_cache", + ".nox", + ".pants.d", + ".pyenv", + ".pytest_cache", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + ".vscode", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "site-packages", + "venv", +] + +[tool.ruff.lint] +ignore = ["E731"] +extend-ignore = ["E203"] +select = [ + "I", # isort - import sorting + "F401", # unused imports +] + + +[tool.pytest.ini_options] +testpaths = [ + "tests", +] + +[tool.mypy] +python_version = "3.10" + +ignore_missing_imports = true +namespace_packages = true +explicit_package_bases = true +scripts_are_modules = true +pretty = true +show_error_context = true +follow_imports_for_stubs = true +warn_redundant_casts = true +warn_unused_ignores = true +# subset of mypy --strict +# https://mypy.readthedocs.io/en/stable/config_file.html +check_untyped_defs = true +disallow_incomplete_defs = true +warn_return_any = true +strict_equality = true +disallow_untyped_defs = true +exclude = [ + "src/cloudevents/v1", +] diff --git a/requirements/dev.txt b/requirements/dev.txt deleted file mode 100644 index fa910283..00000000 --- a/requirements/dev.txt +++ /dev/null @@ -1,8 +0,0 @@ -black -isort -flake8 -pep8-naming -flake8-print -tox -pre-commit -mypy diff --git a/requirements/mypy.txt b/requirements/mypy.txt deleted file mode 100644 index 2f2229cf..00000000 --- a/requirements/mypy.txt +++ /dev/null @@ -1,5 +0,0 @@ -mypy -# mypy has the pydantic plugin enabled -pydantic>=2.0.0,<3.0 -types-requests -deprecation>=2.0,<3.0 diff --git a/requirements/publish.txt b/requirements/publish.txt deleted file mode 100644 index a296666f..00000000 --- a/requirements/publish.txt +++ /dev/null @@ -1,2 +0,0 @@ -GitPython -cloudevents diff --git a/requirements/test.txt b/requirements/test.txt deleted file mode 100644 index 729eeec9..00000000 --- a/requirements/test.txt +++ /dev/null @@ -1,13 +0,0 @@ -flake8 -pep8-naming -flake8-print -pytest>=8.0.0,<9.0 -pytest-cov -# web app tests -sanic -sanic-testing -aiohttp -Pillow -requests -flask -pydantic>=2.0.0,<3.0 diff --git a/samples/http-image-cloudevents/README.md b/samples/http-image-cloudevents/README.md index adec0340..92c1d8a0 100644 --- a/samples/http-image-cloudevents/README.md +++ b/samples/http-image-cloudevents/README.md @@ -3,20 +3,20 @@ Install dependencies: ```sh -pip3 install -r requirements.txt +pip install -r requirements.txt ``` Start server: ```sh -python3 image_sample_server.py +python image_sample_server.py ``` In a new shell, run the client code which sends a structured and binary cloudevent to your local server: ```sh -python3 client.py http://localhost:3000/ +python client.py http://localhost:3000/ ``` ## Test diff --git a/samples/http-image-cloudevents/client.py b/samples/http-image-cloudevents/client.py index ee003942..4be242ba 100644 --- a/samples/http-image-cloudevents/client.py +++ b/samples/http-image-cloudevents/client.py @@ -16,8 +16,11 @@ import requests -from cloudevents.conversion import to_binary, to_structured -from cloudevents.http import CloudEvent +from cloudevents.core.bindings.http import ( + to_binary_event, + to_structured_event, +) +from cloudevents.core.v1.event import CloudEvent resp = requests.get( "https://raw.githubusercontent.com/cncf/artwork/master/projects/cloudevents/horizontal/color/cloudevents-horizontal-color.png" # noqa @@ -28,6 +31,8 @@ def send_binary_cloud_event(url: str) -> None: # Create cloudevent attributes = { + "id": "123", + "specversion": "1.0", "type": "com.example.string", "source": "https://example.com/event-producer", } @@ -35,16 +40,18 @@ def send_binary_cloud_event(url: str) -> None: event = CloudEvent(attributes, image_bytes) # Create cloudevent HTTP headers and content - headers, body = to_binary(event) + http_message = to_binary_event(event) # Send cloudevent - requests.post(url, headers=headers, data=body) - print(f"Sent {event['id']} of type {event['type']}") + requests.post(url, headers=http_message.headers, data=http_message.body) + print(f"Sent {event.get_id()} of type {event.get_type()}") def send_structured_cloud_event(url: str) -> None: # Create cloudevent attributes = { + "id": "123", + "specversion": "1.0", "type": "com.example.base64", "source": "https://example.com/event-producer", } @@ -55,17 +62,17 @@ def send_structured_cloud_event(url: str) -> None: # Note that to_structured will create a data_base64 data field in # specversion 1.0 (default specversion) if given # an event whose data field is of type bytes. - headers, body = to_structured(event) + http_message = to_structured_event(event) # Send cloudevent - requests.post(url, headers=headers, data=body) - print(f"Sent {event['id']} of type {event['type']}") + requests.post(url, headers=http_message.headers, data=http_message.body) + print(f"Sent {event.get_id()} of type {event.get_type()}") if __name__ == "__main__": # Run client.py via: 'python3 client.py http://localhost:3000/' if len(sys.argv) < 2: - sys.exit("Usage: python with_requests.py ") + sys.exit("Usage: python client.py ") url = sys.argv[1] send_binary_cloud_event(url) diff --git a/samples/http-image-cloudevents/image_sample_server.py b/samples/http-image-cloudevents/image_sample_server.py index da303025..900d5c0a 100644 --- a/samples/http-image-cloudevents/image_sample_server.py +++ b/samples/http-image-cloudevents/image_sample_server.py @@ -17,7 +17,7 @@ from flask import Flask, request from PIL import Image -from cloudevents.http import from_http +from cloudevents.core.bindings.http import HTTPMessage, from_http_event app = Flask(__name__) @@ -26,17 +26,15 @@ def home(): # Create a CloudEvent. # data_unmarshaller will cast event.data into an io.BytesIO object - event = from_http( - request.headers, - request.get_data(), - data_unmarshaller=lambda x: io.BytesIO(x), + event = from_http_event( + HTTPMessage(headers=dict(request.headers), body=request.get_data()) ) # Create image from cloudevent data - image = Image.open(event.data) + image = Image.open(io.BytesIO(event.get_data())) # Print - print(f"Found event {event['id']} with image of size {image.size}") + print(f"Found event {event.get_id()} with image of size {image.size}") return f"Found image of size {image.size}", 200 diff --git a/samples/http-image-cloudevents/image_sample_test.py b/samples/http-image-cloudevents/image_sample_test.py index 5fe6ec9d..0d2ecc22 100644 --- a/samples/http-image-cloudevents/image_sample_test.py +++ b/samples/http-image-cloudevents/image_sample_test.py @@ -21,8 +21,12 @@ from image_sample_server import app from PIL import Image -from cloudevents.conversion import to_binary, to_structured -from cloudevents.http import CloudEvent, from_http +from cloudevents.core.bindings.http import ( + from_http_event, + to_binary_event, + to_structured_event, +) +from cloudevents.core.v1.event import CloudEvent image_fileobj = io.BytesIO(image_bytes) image_expected_shape = (1880, 363) @@ -37,6 +41,8 @@ def client(): def test_create_binary_image(): # Create image and turn image into bytes attributes = { + "id": "123", + "specversion": "1.0", "type": "com.example.string", "source": "https://example.com/event-producer", } @@ -45,25 +51,24 @@ def test_create_binary_image(): event = CloudEvent(attributes, image_bytes) # Create http headers/body content - headers, body = to_binary(event) + http_message = to_binary_event(event) # Unmarshall CloudEvent and re-create image - reconstruct_event = from_http( - headers, body, data_unmarshaller=lambda x: io.BytesIO(x) - ) + reconstruct_event = from_http_event(http_message) - # reconstruct_event.data is an io.BytesIO object due to data_unmarshaller - restore_image = Image.open(reconstruct_event.data) + restore_image = Image.open(io.BytesIO(reconstruct_event.get_data())) assert restore_image.size == image_expected_shape # # Test cloudevent extension from http fields and data - assert isinstance(body, bytes) - assert body == image_bytes + assert isinstance(http_message.body, bytes) + assert http_message.body == image_bytes def test_create_structured_image(): # Create image and turn image into bytes attributes = { + "id": "123", + "specversion": "1.0", "type": "com.example.string", "source": "https://example.com/event-producer", } @@ -72,29 +77,28 @@ def test_create_structured_image(): event = CloudEvent(attributes, image_bytes) # Create http headers/body content - headers, body = to_structured(event) + http_message = to_structured_event(event) # Structured has cloudevent attributes marshalled inside the body. For this # reason we must load the byte object to create the python dict containing # the cloudevent attributes - data = json.loads(body) + data = json.loads(http_message.body.decode()) # Test cloudevent extension from http fields and data assert isinstance(data, dict) assert base64.b64decode(data["data_base64"]) == image_bytes # Unmarshall CloudEvent and re-create image - reconstruct_event = from_http( - headers, body, data_unmarshaller=lambda x: io.BytesIO(x) - ) + reconstruct_event = from_http_event(http_message) - # reconstruct_event.data is an io.BytesIO object due to data_unmarshaller - restore_image = Image.open(reconstruct_event.data) + restore_image = Image.open(io.BytesIO(reconstruct_event.get_data())) assert restore_image.size == image_expected_shape def test_server_structured(client): attributes = { + "id": "123", + "specversion": "1.0", "type": "com.example.base64", "source": "https://example.com/event-producer", } @@ -105,10 +109,10 @@ def test_server_structured(client): # Note that to_structured will create a data_base64 data field in # specversion 1.0 (default specversion) if given # an event whose data field is of type bytes. - headers, body = to_structured(event) + http_message = to_structured_event(event) # Send cloudevent - r = client.post("/", headers=headers, data=body) + r = client.post("/", headers=http_message.headers, data=http_message.body) assert r.status_code == 200 assert r.data.decode() == f"Found image of size {image_expected_shape}" @@ -116,6 +120,8 @@ def test_server_structured(client): def test_server_binary(client): # Create cloudevent attributes = { + "id": "123", + "specversion": "1.0", "type": "com.example.string", "source": "https://example.com/event-producer", } @@ -123,10 +129,10 @@ def test_server_binary(client): event = CloudEvent(attributes, image_bytes) # Create cloudevent HTTP headers and content - headers, body = to_binary(event) + http_message = to_binary_event(event) # Send cloudevent - r = client.post("/", headers=headers, data=body) + r = client.post("/", headers=http_message.headers, data=http_message.body) assert r.status_code == 200 assert r.data.decode() == f"Found image of size {image_expected_shape}" diff --git a/samples/http-image-cloudevents/requirements.txt b/samples/http-image-cloudevents/requirements.txt index ea5705ba..6be23fd3 100644 --- a/samples/http-image-cloudevents/requirements.txt +++ b/samples/http-image-cloudevents/requirements.txt @@ -1,5 +1,5 @@ flask requests Pillow -pytest>=8.0.0,<9.0 -cloudevents +pytest +cloudevents==2.0.0 diff --git a/samples/http-json-cloudevents/README.md b/samples/http-json-cloudevents/README.md index 38447da0..02ea42a6 100644 --- a/samples/http-json-cloudevents/README.md +++ b/samples/http-json-cloudevents/README.md @@ -3,20 +3,20 @@ Install dependencies: ```sh -pip3 install -r requirements.txt +pip install -r requirements.txt ``` Start server: ```sh -python3 json_sample_server.py +python json_sample_server.py ``` In a new shell, run the client code which sends a structured and binary cloudevent to your local server: ```sh -python3 client.py http://localhost:3000/ +python client.py http://localhost:3000/ ``` ## Test diff --git a/samples/http-json-cloudevents/client.py b/samples/http-json-cloudevents/client.py index 5ecc3793..fb9af095 100644 --- a/samples/http-json-cloudevents/client.py +++ b/samples/http-json-cloudevents/client.py @@ -16,47 +16,54 @@ import requests -from cloudevents.conversion import to_binary, to_structured -from cloudevents.http import CloudEvent +from cloudevents.core.bindings.http import ( + to_binary_event, + to_structured_event, +) +from cloudevents.core.v1.event import CloudEvent def send_binary_cloud_event(url): # This data defines a binary cloudevent attributes = { + "id": "123", + "specversion": "1.0", "type": "com.example.sampletype1", "source": "https://example.com/event-producer", } data = {"message": "Hello World!"} event = CloudEvent(attributes, data) - headers, body = to_binary(event) + http_message = to_binary_event(event) # send and print event - requests.post(url, headers=headers, data=body) - print(f"Sent {event['id']} from {event['source']} with {event.data}") + requests.post(url, headers=http_message.headers, data=http_message.body) + print(f"Sent {event.get_id()} from {event.get_source()} with {event.get_data()}") def send_structured_cloud_event(url): - # This data defines a binary cloudevent + # This data defines a structured cloudevent attributes = { + "id": "123", + "specversion": "1.0", "type": "com.example.sampletype2", "source": "https://example.com/event-producer", } data = {"message": "Hello World!"} event = CloudEvent(attributes, data) - headers, body = to_structured(event) + http_message = to_structured_event(event) # send and print event - requests.post(url, headers=headers, data=body) - print(f"Sent {event['id']} from {event['source']} with {event.data}") + requests.post(url, headers=http_message.headers, data=http_message.body) + print(f"Sent {event.get_id()} from {event.get_source()} with {event.get_data()}") if __name__ == "__main__": # expects a url from command line. - # e.g. python3 client.py http://localhost:3000/ + # e.g. python client.py http://localhost:3000/ if len(sys.argv) < 2: - sys.exit("Usage: python with_requests.py ") + sys.exit("Usage: python client.py ") url = sys.argv[1] send_binary_cloud_event(url) diff --git a/samples/http-json-cloudevents/json_sample_server.py b/samples/http-json-cloudevents/json_sample_server.py index c3a399ee..507d09ba 100644 --- a/samples/http-json-cloudevents/json_sample_server.py +++ b/samples/http-json-cloudevents/json_sample_server.py @@ -14,21 +14,21 @@ from flask import Flask, request -from cloudevents.http import from_http +from cloudevents.core.bindings.http import HTTPMessage, from_http_event app = Flask(__name__) -# create an endpoint at http://localhost:/3000/ +# create an endpoint at http://localhost:3000/ @app.route("/", methods=["POST"]) def home(): # create a CloudEvent - event = from_http(request.headers, request.get_data()) + event = from_http_event(HTTPMessage(dict(request.headers), request.get_data())) # you can access cloudevent fields as seen below print( - f"Found {event['id']} from {event['source']} with type " - f"{event['type']} and specversion {event['specversion']}" + f"Found {event.get_id()} from {event.get_source()} with type " + f"{event.get_type()} and specversion {event.get_specversion()}" ) return "", 204 diff --git a/samples/http-json-cloudevents/json_sample_test.py b/samples/http-json-cloudevents/json_sample_test.py index 1d92874d..f934dc03 100644 --- a/samples/http-json-cloudevents/json_sample_test.py +++ b/samples/http-json-cloudevents/json_sample_test.py @@ -15,8 +15,11 @@ import pytest from json_sample_server import app -from cloudevents.conversion import to_binary, to_structured -from cloudevents.http import CloudEvent +from cloudevents.core.bindings.http import ( + to_binary_event, + to_structured_event, +) +from cloudevents.core.v1.event import CloudEvent @pytest.fixture @@ -28,28 +31,32 @@ def client(): def test_binary_request(client): # This data defines a binary cloudevent attributes = { + "id": "123", + "specversion": "1.0", "type": "com.example.sampletype1", "source": "https://example.com/event-producer", } data = {"message": "Hello World!"} event = CloudEvent(attributes, data) - headers, body = to_binary(event) + http_message = to_binary_event(event) - r = client.post("/", headers=headers, data=body) + r = client.post("/", headers=http_message.headers, data=http_message.body) assert r.status_code == 204 def test_structured_request(client): - # This data defines a binary cloudevent + # This data defines a structured cloudevent attributes = { + "id": "123", + "specversion": "1.0", "type": "com.example.sampletype2", "source": "https://example.com/event-producer", } data = {"message": "Hello World!"} event = CloudEvent(attributes, data) - headers, body = to_structured(event) + http_message = to_structured_event(event) - r = client.post("/", headers=headers, data=body) + r = client.post("/", headers=http_message.headers, data=http_message.body) assert r.status_code == 204 diff --git a/samples/http-json-cloudevents/requirements.txt b/samples/http-json-cloudevents/requirements.txt index cd751360..8d348c9c 100644 --- a/samples/http-json-cloudevents/requirements.txt +++ b/samples/http-json-cloudevents/requirements.txt @@ -1,4 +1,4 @@ flask requests -pytest>=8.0.0,<9.0 -cloudevents +pytest +cloudevents==2.0.0 diff --git a/setup.py b/setup.py deleted file mode 100644 index f4249978..00000000 --- a/setup.py +++ /dev/null @@ -1,84 +0,0 @@ -# Copyright 2018-Present The CloudEvents Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. - -import codecs -import os -import pathlib - -from setuptools import find_packages, setup - - -def read(rel_path): - here = os.path.abspath(os.path.dirname(__file__)) - with codecs.open(os.path.join(here, rel_path), "r") as fp: - return fp.read() - - -def get_version(rel_path): - for line in read(rel_path).splitlines(): - if line.startswith("__version__"): - delim = '"' if '"' in line else "'" - return line.split(delim)[1] - else: - raise RuntimeError("Unable to find version string.") - - -# FORMAT: 1.x.x -pypi_config = { - "version_target": get_version("cloudevents/__init__.py"), - "package_name": "cloudevents", -} - -here = pathlib.Path(__file__).parent.resolve() -long_description = (here / "README.md").read_text(encoding="utf-8") - -if __name__ == "__main__": - setup( - name=pypi_config["package_name"], - summary="CloudEvents Python SDK", - long_description_content_type="text/markdown", - long_description=long_description, - description="CloudEvents Python SDK", - url="https://github.com/cloudevents/sdk-python", - author="The Cloud Events Contributors", - author_email="cncfcloudevents@gmail.com", - home_page="https://cloudevents.io", - classifiers=[ - "Intended Audience :: Information Technology", - "Intended Audience :: System Administrators", - "Intended Audience :: Developers", - "License :: OSI Approved :: Apache Software License", - "Development Status :: 5 - Production/Stable", - "Operating System :: OS Independent", - "Natural Language :: English", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "Typing :: Typed", - ], - keywords="CloudEvents Eventing Serverless", - license="https://www.apache.org/licenses/LICENSE-2.0", - license_file="LICENSE", - packages=find_packages(exclude=["cloudevents.tests"]), - include_package_data=True, - version=pypi_config["version_target"], - install_requires=["deprecation>=2.0,<3.0"], - extras_require={"pydantic": "pydantic>=1.0.0,<3.0"}, - zip_safe=True, - ) diff --git a/src/cloudevents/__init__.py b/src/cloudevents/__init__.py new file mode 100644 index 00000000..81921467 --- /dev/null +++ b/src/cloudevents/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +__version__ = "2.0.0" diff --git a/src/cloudevents/core/__init__.py b/src/cloudevents/core/__init__.py new file mode 100644 index 00000000..e01d2a11 --- /dev/null +++ b/src/cloudevents/core/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""This package contains the core functionality of the CloudEvents spec.""" diff --git a/src/cloudevents/core/base.py b/src/cloudevents/core/base.py new file mode 100644 index 00000000..747f5a6a --- /dev/null +++ b/src/cloudevents/core/base.py @@ -0,0 +1,145 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from datetime import datetime +from typing import Any, Callable, Protocol + +EventFactory = Callable[ + [dict[str, Any], dict[str, Any] | str | bytes | None], "BaseCloudEvent" +] +""" +Type alias for a callable that creates a BaseCloudEvent from attributes and data. + +Args: + attributes: The CloudEvent attributes (required fields like id, source, type, etc.) + data: The CloudEvent data payload (optional) + +Returns: + A BaseCloudEvent instance +""" + + +class BaseCloudEvent(Protocol): + """ + The CloudEvent Python wrapper contract exposing generically-available + properties and APIs. + + Implementations might handle fields and have other APIs exposed but are + obliged to follow this contract. + """ + + def __init__( + self, + attributes: dict[str, Any], + data: dict[str, Any] | str | bytes | None = None, + ) -> None: + """ + Create a new CloudEvent instance. + + :param attributes: The attributes of the CloudEvent instance. + :param data: The payload of the CloudEvent instance. + + :raises ValueError: If any of the required attributes are missing or have invalid values. + :raises TypeError: If any of the attributes have invalid types. + """ + ... + + def get_id(self) -> str: + """ + Retrieve the ID of the event. + + :return: The ID of the event. + """ + ... + + def get_source(self) -> str: + """ + Retrieve the source of the event. + + :return: The source of the event. + """ + ... + + def get_type(self) -> str: + """ + Retrieve the type of the event. + + :return: The type of the event. + """ + ... + + def get_specversion(self) -> str: + """ + Retrieve the specversion of the event. + + :return: The specversion of the event. + """ + ... + + def get_datacontenttype(self) -> str | None: + """ + Retrieve the datacontenttype of the event. + + :return: The datacontenttype of the event. + """ + ... + + def get_dataschema(self) -> str | None: + """ + Retrieve the dataschema of the event. + + :return: The dataschema of the event. + """ + ... + + def get_subject(self) -> str | None: + """ + Retrieve the subject of the event. + + :return: The subject of the event. + """ + ... + + def get_time(self) -> datetime | None: + """ + Retrieve the time of the event. + + :return: The time of the event. + """ + ... + + def get_extension(self, extension_name: str) -> Any: + """ + Retrieve an extension attribute of the event. + + :param extension_name: The name of the extension attribute. + :return: The value of the extension attribute. + """ + ... + + def get_data(self) -> dict[str, Any] | str | bytes | None: + """ + Retrieve data of the event. + + :return: The data of the event. + """ + ... + + def get_attributes(self) -> dict[str, Any]: + """ + Retrieve all attributes of the event. + + :return: The attributes of the event. + """ + ... diff --git a/src/cloudevents/core/bindings/__init__.py b/src/cloudevents/core/bindings/__init__.py new file mode 100644 index 00000000..2379308a --- /dev/null +++ b/src/cloudevents/core/bindings/__init__.py @@ -0,0 +1,20 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +CloudEvents protocol bindings. + +This package provides protocol-specific bindings for CloudEvents, including HTTP and Kafka. +Each binding module provides functions to convert CloudEvents to/from protocol-specific messages. +""" diff --git a/src/cloudevents/core/bindings/amqp.py b/src/cloudevents/core/bindings/amqp.py new file mode 100644 index 00000000..7791c888 --- /dev/null +++ b/src/cloudevents/core/bindings/amqp.py @@ -0,0 +1,462 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Final + +from dateutil.parser import isoparse + +from cloudevents.core.base import BaseCloudEvent, EventFactory +from cloudevents.core.bindings.common import get_event_factory_for_version +from cloudevents.core.formats.base import Format +from cloudevents.core.formats.json import JSONFormat +from cloudevents.core.spec import SPECVERSION_V1_0 +from cloudevents.core.v1.event import CloudEvent + +# AMQP CloudEvents spec allows both cloudEvents_ and cloudEvents: prefixes +# The underscore variant is preferred for JMS 2.0 compatibility +CE_PREFIX_UNDERSCORE: Final[str] = "cloudEvents_" +CE_PREFIX_COLON: Final[str] = "cloudEvents:" +CONTENT_TYPE_PROPERTY: Final[str] = "content-type" + + +@dataclass(frozen=True) +class AMQPMessage: + """ + Represents an AMQP 1.0 message containing CloudEvent data. + + This dataclass encapsulates AMQP message properties, application properties, + and application data for transmitting CloudEvents over AMQP. It is immutable + to prevent accidental modifications and works with any AMQP 1.0 library + (e.g., Pika, aio-pika, qpid-proton, azure-servicebus). + + Attributes: + properties: AMQP message properties as a dictionary + application_properties: AMQP application properties as a dictionary + application_data: AMQP application data section as bytes + """ + + properties: dict[str, Any] + application_properties: dict[str, Any] + application_data: bytes + + +def _encode_amqp_value(value: Any) -> Any: + """ + Encode a CloudEvent attribute value for AMQP application properties. + + Handles special encoding for datetime objects to AMQP timestamp type + (milliseconds since Unix epoch as int). Per AMQP 1.0 CloudEvents spec, + senders SHOULD use native AMQP types when efficient. + + :param value: The attribute value to encode + :return: Encoded value (int for datetime timestamp, original type otherwise) + """ + if isinstance(value, datetime): + # AMQP 1.0 timestamp: milliseconds since Unix epoch (UTC) + timestamp_ms = int(value.timestamp() * 1000) + return timestamp_ms + + return value + + +def _decode_amqp_value(attr_name: str, value: Any) -> Any: + """ + Decode a CloudEvent attribute value from AMQP application properties. + + Handles special parsing for the 'time' attribute. Per AMQP 1.0 CloudEvents spec, + receivers MUST accept both native AMQP timestamp (int milliseconds since epoch) + and canonical string form (ISO 8601). + + :param attr_name: The name of the CloudEvent attribute + :param value: The AMQP property value + :return: Decoded value (datetime for 'time' attribute, original type otherwise) + """ + if attr_name == "time": + if isinstance(value, int): + # AMQP timestamp: milliseconds since Unix epoch + return datetime.fromtimestamp(value / 1000.0, tz=timezone.utc) + if isinstance(value, str): + # ISO 8601 string (canonical form, also accepted per spec) + return isoparse(value) + + return value + + +def to_binary(event: BaseCloudEvent, event_format: Format) -> AMQPMessage: + """ + Convert a CloudEvent to AMQP binary content mode. + + In binary mode, CloudEvent attributes are mapped to AMQP application properties + with the 'cloudEvents_' prefix, except for 'datacontenttype' which maps to the + AMQP 'content-type' property. The event data is placed directly in the AMQP + application-data section. Datetime values are encoded as AMQP timestamp type + (milliseconds since Unix epoch), while boolean and integer values are preserved + as native types. + + Note: Per AMQP CloudEvents spec, attributes may use 'cloudEvents_' or 'cloudEvents:' + prefix. This implementation uses 'cloudEvents_' for JMS 2.0 compatibility. + + Example: + >>> from cloudevents.core.v1.event import CloudEvent + >>> from cloudevents.core.formats.json import JSONFormat + >>> + >>> event = CloudEvent( + ... attributes={"type": "com.example.test", "source": "/test"}, + ... data={"message": "Hello"} + ... ) + >>> message = to_binary(event, JSONFormat()) + >>> # message.application_properties = {"cloudEvents_type": "com.example.test", ...} + >>> # message.properties = {"content-type": "application/json"} + >>> # message.application_data = b'{"message": "Hello"}' + + :param event: The CloudEvent to convert + :param event_format: Format implementation for data serialization + :return: AMQPMessage with CloudEvent attributes as application properties + """ + properties: dict[str, Any] = {} + application_properties: dict[str, Any] = {} + attributes = event.get_attributes() + + for attr_name, attr_value in attributes.items(): + if attr_name == "datacontenttype": + properties[CONTENT_TYPE_PROPERTY] = str(attr_value) + else: + property_name = f"{CE_PREFIX_UNDERSCORE}{attr_name}" + # Encode datetime to AMQP timestamp (milliseconds since epoch) + # Other types (bool, int, str, bytes) use native AMQP types + application_properties[property_name] = _encode_amqp_value(attr_value) + + data = event.get_data() + datacontenttype = attributes.get("datacontenttype") + application_data = event_format.write_data(data, datacontenttype) + + return AMQPMessage( + properties=properties, + application_properties=application_properties, + application_data=application_data, + ) + + +def from_binary( + message: AMQPMessage, + event_format: Format, + event_factory: EventFactory | None = None, +) -> BaseCloudEvent: + """ + Parse an AMQP binary content mode message to a CloudEvent. + + Auto-detects the CloudEvents version from the application properties + and uses the appropriate event factory if not explicitly provided. + + Extracts CloudEvent attributes from AMQP application properties with either + 'cloudEvents_' or 'cloudEvents:' prefix (per AMQP CloudEvents spec), and treats + the AMQP 'content-type' property as the 'datacontenttype' attribute. The + application-data section is parsed as event data according to the content type. + The 'time' attribute accepts both AMQP timestamp (int milliseconds) and ISO 8601 + string, while other native AMQP types (boolean, integer) are preserved. + + Example: + >>> from cloudevents.core.v1.event import CloudEvent + >>> from cloudevents.core.formats.json import JSONFormat + >>> + >>> message = AMQPMessage( + ... properties={"content-type": "application/json"}, + ... application_properties={ + ... "cloudEvents_type": "com.example.test", + ... "cloudEvents_source": "/test", + ... "cloudEvents_id": "123", + ... "cloudEvents_specversion": "1.0" + ... }, + ... application_data=b'{"message": "Hello"}' + ... ) + >>> event = from_binary(message, JSONFormat(), CloudEvent) + + :param message: AMQPMessage to parse + :param event_format: Format implementation for data deserialization + :param event_factory: Factory function to create CloudEvent instances + :return: CloudEvent instance + """ + attributes: dict[str, Any] = {} + + for prop_name, prop_value in message.application_properties.items(): + # Check for both cloudEvents_ and cloudEvents: prefixes + attr_name = None + + if prop_name.startswith(CE_PREFIX_UNDERSCORE): + attr_name = prop_name[len(CE_PREFIX_UNDERSCORE) :] + elif prop_name.startswith(CE_PREFIX_COLON): + attr_name = prop_name[len(CE_PREFIX_COLON) :] + + if attr_name: + # Decode timestamp (int or ISO 8601 string) to datetime, preserve other native types + attributes[attr_name] = _decode_amqp_value(attr_name, prop_value) + + if CONTENT_TYPE_PROPERTY in message.properties: + attributes["datacontenttype"] = message.properties[CONTENT_TYPE_PROPERTY] + + # Auto-detect version if factory not provided + if event_factory is None: + specversion = attributes.get("specversion", SPECVERSION_V1_0) + event_factory = get_event_factory_for_version(specversion) + + datacontenttype = attributes.get("datacontenttype") + data = event_format.read_data(message.application_data, datacontenttype) + + return event_factory(attributes, data) + + +def to_structured(event: BaseCloudEvent, event_format: Format) -> AMQPMessage: + """ + Convert a CloudEvent to AMQP structured content mode. + + In structured mode, the entire CloudEvent (attributes and data) is serialized + into the AMQP application-data section using the specified format. The + content-type property is set to the format's media type (e.g., + "application/cloudevents+json"). + + Example: + >>> from cloudevents.core.v1.event import CloudEvent + >>> from cloudevents.core.formats.json import JSONFormat + >>> + >>> event = CloudEvent( + ... attributes={"type": "com.example.test", "source": "/test"}, + ... data={"message": "Hello"} + ... ) + >>> message = to_structured(event, JSONFormat()) + >>> # message.properties = {"content-type": "application/cloudevents+json"} + >>> # message.application_data = b'{"type": "com.example.test", ...}' + + :param event: The CloudEvent to convert + :param event_format: Format implementation for serialization + :return: AMQPMessage with structured content in application-data + """ + content_type = event_format.get_content_type() + + properties = {CONTENT_TYPE_PROPERTY: content_type} + application_properties: dict[str, Any] = {} + + application_data = event_format.write(event) + + return AMQPMessage( + properties=properties, + application_properties=application_properties, + application_data=application_data, + ) + + +def from_structured( + message: AMQPMessage, + event_format: Format, + event_factory: EventFactory | None = None, +) -> BaseCloudEvent: + """ + Parse an AMQP structured content mode message to a CloudEvent. + + Deserializes the CloudEvent from the AMQP application-data section using the + specified format. Any cloudEvents_-prefixed application properties are ignored + as the application-data contains all event metadata. + + If event_factory is not provided, version detection is delegated to the format + implementation, which will auto-detect based on the 'specversion' field. + + Example: + >>> from cloudevents.core.v1.event import CloudEvent + >>> from cloudevents.core.formats.json import JSONFormat + >>> + >>> # Explicit factory + >>> message = AMQPMessage( + ... properties={"content-type": "application/cloudevents+json"}, + ... application_properties={}, + ... application_data=b'{"type": "com.example.test", "source": "/test", ...}' + ... ) + >>> event = from_structured(message, JSONFormat(), CloudEvent) + >>> + >>> # Auto-detect version + >>> event = from_structured(message, JSONFormat()) + + :param message: AMQPMessage to parse + :param event_format: Format implementation for deserialization + :param event_factory: Factory function to create CloudEvent instances. + If None, the format will auto-detect the version. + :return: CloudEvent instance + """ + # Delegate version detection to format layer + return event_format.read(event_factory, message.application_data) + + +def from_amqp( + message: AMQPMessage, + event_format: Format, + event_factory: EventFactory | None = None, +) -> BaseCloudEvent: + """ + Parse an AMQP message to a CloudEvent with automatic mode detection. + + Auto-detects CloudEvents version and uses appropriate event factory if not provided. + + Automatically detects whether the message uses binary or structured content mode: + - If content-type starts with "application/cloudevents" → structured mode + - Otherwise → binary mode + + This function provides a convenient way to handle both content modes without + requiring the caller to determine the mode beforehand. + + Example: + >>> from cloudevents.core.v1.event import CloudEvent + >>> from cloudevents.core.formats.json import JSONFormat + >>> + >>> # Works with binary mode + >>> binary_msg = AMQPMessage( + ... properties={"content-type": "application/json"}, + ... application_properties={"cloudEvents_type": "com.example.test", ...}, + ... application_data=b'...' + ... ) + >>> event1 = from_amqp(binary_msg, JSONFormat(), CloudEvent) + >>> + >>> # Also works with structured mode + >>> structured_msg = AMQPMessage( + ... properties={"content-type": "application/cloudevents+json"}, + ... application_properties={}, + ... application_data=b'{"type": "com.example.test", ...}' + ... ) + >>> event2 = from_amqp(structured_msg, JSONFormat(), CloudEvent) + + :param message: AMQPMessage to parse + :param event_format: Format implementation for deserialization + :param event_factory: Factory function to create CloudEvent instances (auto-detected if None) + :return: CloudEvent instance + """ + content_type = message.properties.get(CONTENT_TYPE_PROPERTY, "") + + if isinstance(content_type, str) and content_type.lower().startswith( + "application/cloudevents" + ): + return from_structured(message, event_format, event_factory) + + return from_binary(message, event_format, event_factory) + + +def to_binary_event( + event: BaseCloudEvent, + event_format: Format | None = None, +) -> AMQPMessage: + """ + Convenience wrapper for to_binary with JSON format as default. + + Example: + >>> from cloudevents.core.v1.event import CloudEvent + >>> from cloudevents.core.bindings import amqp + >>> + >>> event = CloudEvent( + ... attributes={"type": "com.example.test", "source": "/test"}, + ... data={"message": "Hello"} + ... ) + >>> message = amqp.to_binary_event(event) + + :param event: The CloudEvent to convert + :param event_format: Format implementation (defaults to JSONFormat) + :return: AMQPMessage with CloudEvent attributes as application properties + """ + if event_format is None: + event_format = JSONFormat() + return to_binary(event, event_format) + + +def from_binary_event( + message: AMQPMessage, + event_format: Format | None = None, +) -> CloudEvent: + """ + Convenience wrapper for from_binary with JSON format and CloudEvent as defaults. + + Example: + >>> from cloudevents.core.bindings import amqp + >>> event = amqp.from_binary_event(message) + + :param message: AMQPMessage to parse + :param event_format: Format implementation (defaults to JSONFormat) + :return: CloudEvent instance + """ + if event_format is None: + event_format = JSONFormat() + return from_binary(message, event_format, CloudEvent) + + +def to_structured_event( + event: BaseCloudEvent, + event_format: Format | None = None, +) -> AMQPMessage: + """ + Convenience wrapper for to_structured with JSON format as default. + + Example: + >>> from cloudevents.core.v1.event import CloudEvent + >>> from cloudevents.core.bindings import amqp + >>> + >>> event = CloudEvent( + ... attributes={"type": "com.example.test", "source": "/test"}, + ... data={"message": "Hello"} + ... ) + >>> message = amqp.to_structured_event(event) + + :param event: The CloudEvent to convert + :param event_format: Format implementation (defaults to JSONFormat) + :return: AMQPMessage with structured content in application-data + """ + if event_format is None: + event_format = JSONFormat() + return to_structured(event, event_format) + + +def from_structured_event( + message: AMQPMessage, + event_format: Format | None = None, +) -> CloudEvent: + """ + Convenience wrapper for from_structured with JSON format and CloudEvent as defaults. + + Example: + >>> from cloudevents.core.bindings import amqp + >>> event = amqp.from_structured_event(message) + + :param message: AMQPMessage to parse + :param event_format: Format implementation (defaults to JSONFormat) + :return: CloudEvent instance + """ + if event_format is None: + event_format = JSONFormat() + return from_structured(message, event_format, CloudEvent) + + +def from_amqp_event( + message: AMQPMessage, + event_format: Format | None = None, +) -> CloudEvent: + """ + Convenience wrapper for from_amqp with JSON format and CloudEvent as defaults. + Auto-detects binary or structured mode. + + Example: + >>> from cloudevents.core.bindings import amqp + >>> event = amqp.from_amqp_event(message) + + :param message: AMQPMessage to parse + :param event_format: Format implementation (defaults to JSONFormat) + :return: CloudEvent instance + """ + if event_format is None: + event_format = JSONFormat() + return from_amqp(message, event_format, CloudEvent) diff --git a/src/cloudevents/core/bindings/common.py b/src/cloudevents/core/bindings/common.py new file mode 100644 index 00000000..05475ac3 --- /dev/null +++ b/src/cloudevents/core/bindings/common.py @@ -0,0 +1,89 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Common utilities for CloudEvents protocol bindings. + +This module provides shared functionality for protocol bindings (HTTP, Kafka, etc.) +to handle CloudEvent attribute encoding and decoding per the CloudEvents specification. +""" + +from datetime import datetime +from typing import Any, Final +from urllib.parse import quote, unquote + +from dateutil.parser import isoparse + +from cloudevents.core.base import EventFactory +from cloudevents.core.spec import SPECVERSION_V0_3 +from cloudevents.core.v03.event import CloudEvent as CloudEventV03 +from cloudevents.core.v1.event import CloudEvent + +TIME_ATTR: Final[str] = "time" +CONTENT_TYPE_HEADER: Final[str] = "content-type" +DATACONTENTTYPE_ATTR: Final[str] = "datacontenttype" + + +def encode_header_value(value: Any) -> str: + """ + Encode a CloudEvent attribute value for use in a protocol header. + + Handles special encoding for datetime objects (ISO 8601 with 'Z' suffix for UTC) + and applies percent-encoding for non-ASCII and special characters per RFC 3986. + + :param value: The attribute value to encode + :return: Percent-encoded string suitable for protocol headers + """ + if isinstance(value, datetime): + str_value = value.isoformat() + if str_value.endswith("+00:00"): + str_value = str_value[:-6] + "Z" + return quote(str_value, safe="") + + return quote(str(value), safe="") + + +def decode_header_value(attr_name: str, value: str) -> Any: + """ + Decode a CloudEvent attribute value from a protocol header. + + Applies percent-decoding and special parsing for the 'time' attribute + (converts to datetime object using RFC 3339 parsing). + + :param attr_name: The name of the CloudEvent attribute + :param value: The percent-encoded header value + :return: Decoded value (datetime for 'time' attribute, string otherwise) + """ + decoded = unquote(value) + + if attr_name == TIME_ATTR: + return isoparse(decoded) + + return decoded + + +def get_event_factory_for_version(specversion: str) -> EventFactory: + """ + Get the appropriate event factory based on the CloudEvents specification version. + + This function returns the CloudEvent class implementation for the specified + version. Used by protocol bindings for automatic version detection. + + :param specversion: The CloudEvents specification version (e.g., "0.3" or "1.0") + :return: EventFactory for the specified version (defaults to v1.0 for unknown versions) + """ + if specversion == SPECVERSION_V0_3: + return CloudEventV03 + # Default to v1.0 for unknown versions + return CloudEvent diff --git a/src/cloudevents/core/bindings/http.py b/src/cloudevents/core/bindings/http.py new file mode 100644 index 00000000..fbdcc3b0 --- /dev/null +++ b/src/cloudevents/core/bindings/http.py @@ -0,0 +1,423 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Final +from urllib.parse import quote, unquote + +from dateutil.parser import isoparse + +from cloudevents.core.base import BaseCloudEvent, EventFactory +from cloudevents.core.bindings.common import ( + CONTENT_TYPE_HEADER, + DATACONTENTTYPE_ATTR, + TIME_ATTR, + get_event_factory_for_version, +) +from cloudevents.core.formats.base import Format +from cloudevents.core.formats.json import JSONFormat +from cloudevents.core.spec import SPECVERSION_V1_0 + +# Per CloudEvents HTTP binding spec (section 3.1.3.2), all printable ASCII +# characters (U+0021-U+007E) are safe EXCEPT space, double-quote, and percent. +_CE_SAFE_CHARS: Final[str] = "".join( + c for c in map(chr, range(0x21, 0x7F)) if c not in (" ", '"', "%") +) + + +def _encode_header_value(value: Any) -> str: + """ + Encode a CloudEvent attribute value for use in an HTTP header. + + Handles datetime objects (ISO 8601 with 'Z' suffix for UTC) and applies + percent-encoding per the CloudEvents HTTP binding spec (section 3.1.3.2). + + :param value: The attribute value to encode + :return: Percent-encoded string suitable for HTTP headers + """ + if isinstance(value, datetime): + str_value = value.isoformat() + if str_value.endswith("+00:00"): + str_value = str_value[:-6] + "Z" + return quote(str_value, safe=_CE_SAFE_CHARS) + return quote(str(value), safe=_CE_SAFE_CHARS) + + +def _decode_header_value(attr_name: str, value: str) -> Any: + """ + Decode a CloudEvent attribute value from an HTTP header. + + Applies percent-decoding and parses the 'time' attribute as datetime. + + :param attr_name: The name of the CloudEvent attribute + :param value: The percent-encoded header value + :return: Decoded value (datetime for 'time' attribute, string otherwise) + """ + decoded = unquote(value) + if attr_name == TIME_ATTR: + return isoparse(decoded) + return decoded + + +CE_PREFIX: Final[str] = "ce-" + + +@dataclass(frozen=True) +class HTTPMessage: + """ + Represents an HTTP message (request or response) containing CloudEvent data. + + This dataclass encapsulates HTTP headers and body for transmitting CloudEvents + over HTTP. It is immutable to prevent accidental modifications and works with + any HTTP framework or library. + + Attributes: + headers: HTTP headers as a dictionary with string keys and values + body: HTTP body as bytes + """ + + headers: dict[str, str] + body: bytes + + +def to_binary(event: BaseCloudEvent, event_format: Format) -> HTTPMessage: + """ + Convert a CloudEvent to HTTP binary content mode. + + In binary mode, CloudEvent attributes are mapped to HTTP headers with the 'ce-' prefix, + except for 'datacontenttype' which maps to the 'Content-Type' header. The event data + is placed directly in the HTTP body. + + Example: + >>> from cloudevents.core.v1.event import CloudEvent + >>> from cloudevents.core.formats.json import JSONFormat + >>> + >>> event = CloudEvent( + ... attributes={"type": "com.example.test", "source": "/test"}, + ... data={"message": "Hello"} + ... ) + >>> message = to_binary(event, JSONFormat()) + >>> # message.headers = {"ce-type": "com.example.test", "ce-source": "/test", ...} + >>> # message.body = b'{"message": "Hello"}' + + :param event: The CloudEvent to convert + :param event_format: Format implementation for data serialization + :return: HTTPMessage with ce-prefixed headers and event data as body + """ + headers: dict[str, str] = {} + attributes = event.get_attributes() + + for attr_name, attr_value in attributes.items(): + if attr_value is None: + continue + + if attr_name == DATACONTENTTYPE_ATTR: + headers[CONTENT_TYPE_HEADER] = str(attr_value) + else: + header_name = f"{CE_PREFIX}{attr_name}" + headers[header_name] = _encode_header_value(attr_value) + + data = event.get_data() + datacontenttype = attributes.get(DATACONTENTTYPE_ATTR) + body = event_format.write_data(data, datacontenttype) + + return HTTPMessage(headers=headers, body=body) + + +def from_binary( + message: HTTPMessage, + event_format: Format, + event_factory: EventFactory | None = None, +) -> BaseCloudEvent: + """ + Parse an HTTP binary content mode message to a CloudEvent. + + Auto-detects the CloudEvents version from the 'ce-specversion' header + and uses the appropriate event factory if not explicitly provided. + + Extracts CloudEvent attributes from ce-prefixed HTTP headers and treats the + 'Content-Type' header as the 'datacontenttype' attribute. The HTTP body is + parsed as event data according to the content type. + + Example: + >>> from cloudevents.core.v1.event import CloudEvent + >>> from cloudevents.core.formats.json import JSONFormat + >>> + >>> message = HTTPMessage( + ... headers={"ce-type": "com.example.test", "ce-source": "/test", + ... "ce-id": "123", "ce-specversion": "1.0"}, + ... body=b'{"message": "Hello"}' + ... ) + >>> event = from_binary(message, JSONFormat(), CloudEvent) + + :param message: HTTPMessage to parse + :param event_format: Format implementation for data deserialization + :param event_factory: Factory function to create CloudEvent instances (auto-detected if None) + :return: CloudEvent instance + """ + attributes: dict[str, Any] = {} + + for header_name, header_value in message.headers.items(): + normalized_name = header_name.lower() + + if normalized_name.startswith(CE_PREFIX): + attr_name = normalized_name[len(CE_PREFIX) :] + attributes[attr_name] = _decode_header_value(attr_name, header_value) + elif normalized_name == CONTENT_TYPE_HEADER: + attributes[DATACONTENTTYPE_ATTR] = header_value + + # Auto-detect version if factory not provided + if event_factory is None: + specversion = attributes.get("specversion", SPECVERSION_V1_0) + event_factory = get_event_factory_for_version(specversion) + + datacontenttype = attributes.get(DATACONTENTTYPE_ATTR) + data = event_format.read_data(message.body, datacontenttype) + + return event_factory(attributes, data) + + +def to_structured(event: BaseCloudEvent, event_format: Format) -> HTTPMessage: + """ + Convert a CloudEvent to HTTP structured content mode. + + In structured mode, the entire CloudEvent (attributes and data) is serialized + into the HTTP body using the specified format. The Content-Type header is set + to the format's media type. + + Example: + >>> from cloudevents.core.v1.event import CloudEvent + >>> from cloudevents.core.formats.json import JSONFormat + >>> + >>> event = CloudEvent( + ... attributes={"type": "com.example.test", "source": "/test"}, + ... data={"message": "Hello"} + ... ) + >>> message = to_structured(event, JSONFormat()) + >>> # message.headers = {"content-type": "application/cloudevents+json"} + >>> # message.body = b'{"type": "com.example.test", "source": "/test", ...}' + + :param event: The CloudEvent to convert + :param event_format: Format implementation for serialization + :return: HTTPMessage with structured content in body + """ + content_type = event_format.get_content_type() + + headers = {CONTENT_TYPE_HEADER: content_type} + + body = event_format.write(event) + + return HTTPMessage(headers=headers, body=body) + + +def from_structured( + message: HTTPMessage, + event_format: Format, + event_factory: EventFactory | None = None, +) -> BaseCloudEvent: + """ + Parse an HTTP structured content mode message to a CloudEvent. + + Deserializes the CloudEvent from the HTTP body using the specified format. + Any ce-prefixed headers are ignored as the body contains all event metadata. + + If event_factory is not provided, version detection is delegated to the format + implementation, which will auto-detect based on the 'specversion' field. + + Example: + >>> from cloudevents.core.v1.event import CloudEvent + >>> from cloudevents.core.formats.json import JSONFormat + >>> + >>> # Explicit factory (recommended for performance) + >>> message = HTTPMessage( + ... headers={"content-type": "application/cloudevents+json"}, + ... body=b'{"type": "com.example.test", "source": "/test", ...}' + ... ) + >>> event = from_structured(message, JSONFormat(), CloudEvent) + >>> + >>> # Auto-detect version (convenient) + >>> event = from_structured(message, JSONFormat()) + + :param message: HTTPMessage to parse + :param event_format: Format implementation for deserialization + :param event_factory: Factory function to create CloudEvent instances. + If None, the format will auto-detect the version. + :return: CloudEvent instance + """ + # Delegate version detection to format layer + return event_format.read(event_factory, message.body) + + +def from_http( + message: HTTPMessage, + event_format: Format, + event_factory: EventFactory | None = None, +) -> BaseCloudEvent: + """ + Parse an HTTP message to a CloudEvent with automatic mode detection. + + Auto-detects CloudEvents version and uses appropriate event factory if not provided. + + Automatically detects whether the message uses binary or structured content mode: + - If any ce- prefixed headers are present → binary mode + - Otherwise → structured mode + + This function provides a convenient way to handle both content modes without + requiring the caller to determine the mode beforehand. + + Example: + >>> from cloudevents.core.v1.event import CloudEvent + >>> from cloudevents.core.formats.json import JSONFormat + >>> + >>> # Works with binary mode + >>> binary_msg = HTTPMessage( + ... headers={"ce-type": "com.example.test", ...}, + ... body=b'...' + ... ) + >>> event1 = from_http(binary_msg, JSONFormat(), CloudEvent) + >>> + >>> # Also works with structured mode + >>> structured_msg = HTTPMessage( + ... headers={"content-type": "application/cloudevents+json"}, + ... body=b'{"type": "com.example.test", ...}' + ... ) + >>> event2 = from_http(structured_msg, JSONFormat(), CloudEvent) + + :param message: HTTPMessage to parse + :param event_format: Format implementation for deserialization + :param event_factory: Factory function to create CloudEvent instances (auto-detected if None) + :return: CloudEvent instance + """ + if any(key.lower().startswith(CE_PREFIX) for key in message.headers.keys()): + return from_binary(message, event_format, event_factory) + + return from_structured(message, event_format, event_factory) + + +def to_binary_event( + event: BaseCloudEvent, + event_format: Format | None = None, +) -> HTTPMessage: + """ + Convenience wrapper for to_binary with JSON format as default. + + Example: + >>> from cloudevents.core.v1.event import CloudEvent + >>> from cloudevents.core.bindings import http + >>> + >>> event = CloudEvent( + ... attributes={"type": "com.example.test", "source": "/test"}, + ... data={"message": "Hello"} + ... ) + >>> message = http.to_binary_event(event) + + :param event: The CloudEvent to convert + :param event_format: Format implementation (defaults to JSONFormat) + :return: HTTPMessage with ce-prefixed headers + """ + if event_format is None: + event_format = JSONFormat() + return to_binary(event, event_format) + + +def from_binary_event( + message: HTTPMessage, + event_format: Format | None = None, +) -> BaseCloudEvent: + """ + Convenience wrapper for from_binary with JSON format and auto-detection. + + Auto-detects CloudEvents version (v0.3 or v1.0) from headers. + + Example: + >>> from cloudevents.core.bindings import http + >>> event = http.from_binary_event(message) + + :param message: HTTPMessage to parse + :param event_format: Format implementation (defaults to JSONFormat) + :return: CloudEvent instance (v0.3 or v1.0 based on specversion) + """ + if event_format is None: + event_format = JSONFormat() + return from_binary(message, event_format, None) + + +def to_structured_event( + event: BaseCloudEvent, + event_format: Format | None = None, +) -> HTTPMessage: + """ + Convenience wrapper for to_structured with JSON format as default. + + Example: + >>> from cloudevents.core.v1.event import CloudEvent + >>> from cloudevents.core.bindings import http + >>> + >>> event = CloudEvent( + ... attributes={"type": "com.example.test", "source": "/test"}, + ... data={"message": "Hello"} + ... ) + >>> message = http.to_structured_event(event) + + :param event: The CloudEvent to convert + :param event_format: Format implementation (defaults to JSONFormat) + :return: HTTPMessage with structured content + """ + if event_format is None: + event_format = JSONFormat() + return to_structured(event, event_format) + + +def from_structured_event( + message: HTTPMessage, + event_format: Format | None = None, +) -> BaseCloudEvent: + """ + Convenience wrapper for from_structured with JSON format and auto-detection. + + Auto-detects CloudEvents version (v0.3 or v1.0) from body. + + Example: + >>> from cloudevents.core.bindings import http + >>> event = http.from_structured_event(message) + + :param message: HTTPMessage to parse + :param event_format: Format implementation (defaults to JSONFormat) + :return: CloudEvent instance (v0.3 or v1.0 based on specversion) + """ + if event_format is None: + event_format = JSONFormat() + return from_structured(message, event_format, None) + + +def from_http_event( + message: HTTPMessage, + event_format: Format | None = None, +) -> BaseCloudEvent: + """ + Convenience wrapper for from_http with JSON format and auto-detection. + Auto-detects binary or structured mode, and CloudEvents version. + + Example: + >>> from cloudevents.core.bindings import http + >>> event = http.from_http_event(message) + + :param message: HTTPMessage to parse + :param event_format: Format implementation (defaults to JSONFormat) + :return: CloudEvent instance (v0.3 or v1.0 based on specversion) + """ + if event_format is None: + event_format = JSONFormat() + return from_http(message, event_format, None) diff --git a/src/cloudevents/core/bindings/kafka.py b/src/cloudevents/core/bindings/kafka.py new file mode 100644 index 00000000..dae9e496 --- /dev/null +++ b/src/cloudevents/core/bindings/kafka.py @@ -0,0 +1,467 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Callable, Final + +from dateutil.parser import isoparse + +from cloudevents.core.base import BaseCloudEvent, EventFactory +from cloudevents.core.bindings.common import ( + CONTENT_TYPE_HEADER, + DATACONTENTTYPE_ATTR, + TIME_ATTR, + get_event_factory_for_version, +) +from cloudevents.core.formats.base import Format +from cloudevents.core.formats.json import JSONFormat +from cloudevents.core.spec import SPECVERSION_V1_0 + +CE_PREFIX: Final[str] = "ce_" +PARTITIONKEY_ATTR: Final[str] = "partitionkey" + +KeyMapper = Callable[[BaseCloudEvent], str | bytes | None] + + +@dataclass(frozen=True) +class KafkaMessage: + """ + Represents a Kafka message containing CloudEvent data. + + This dataclass encapsulates Kafka message components for transmitting CloudEvents + over Kafka. It is immutable to prevent accidental modifications and works with + any Kafka client library (kafka-python, confluent-kafka, etc.). + + Attributes: + headers: Kafka message headers as bytes (per Kafka protocol requirement) + key: Optional Kafka message key for partitioning + value: Kafka message value/payload as bytes + """ + + headers: dict[str, bytes] + key: str | bytes | None + value: bytes + + +def _default_key_mapper(event: BaseCloudEvent) -> str | bytes | None: + """ + Default key mapper that extracts the partitionkey extension attribute. + + :param event: The CloudEvent to extract key from + :return: The partitionkey extension attribute value, or None if not present + """ + value = event.get_extension(PARTITIONKEY_ATTR) + # Type narrowing: get_extension returns Any, but we know partitionkey should be str/bytes/None + return value if value is None or isinstance(value, (str, bytes)) else str(value) + + +def to_binary( + event: BaseCloudEvent, + event_format: Format, + key_mapper: KeyMapper | None = None, +) -> KafkaMessage: + """ + Convert a CloudEvent to Kafka binary content mode. + + In binary mode, CloudEvent attributes are mapped to Kafka headers with the 'ce_' prefix, + except for 'datacontenttype' which maps to the 'content-type' header. The event data + is placed in the Kafka message value. The message key is derived from the partitionkey + extension attribute or a custom key_mapper function. + + Example: + >>> from cloudevents.core.v1.event import CloudEvent + >>> from cloudevents.core.formats.json import JSONFormat + >>> + >>> event = CloudEvent( + ... attributes={"type": "com.example.test", "source": "/test"}, + ... data={"message": "Hello"} + ... ) + >>> message = to_binary(event, JSONFormat()) + >>> # message.headers = {"ce_type": b"com.example.test", "ce_source": b"/test", ...} + >>> # message.value = b'{"message": "Hello"}' + >>> # message.key = None + + :param event: The CloudEvent to convert + :param event_format: Format implementation for data serialization + :param key_mapper: Optional function to extract message key from event (defaults to partitionkey attribute) + :return: KafkaMessage with ce_-prefixed headers and event data as value + """ + headers: dict[str, bytes] = {} + attributes = event.get_attributes() + + # Apply key mapper + if key_mapper is None: + key_mapper = _default_key_mapper + message_key = key_mapper(event) + + for attr_name, attr_value in attributes.items(): + if attr_value is None: + continue + + if attr_name == DATACONTENTTYPE_ATTR: + headers[CONTENT_TYPE_HEADER] = str(attr_value).encode("utf-8") + else: + header_name = f"{CE_PREFIX}{attr_name}" + if isinstance(attr_value, datetime): + s = attr_value.isoformat() + if s.endswith("+00:00"): + s = s[:-6] + "Z" + headers[header_name] = s.encode("utf-8") + else: + headers[header_name] = str(attr_value).encode("utf-8") + + data = event.get_data() + datacontenttype = attributes.get(DATACONTENTTYPE_ATTR) + value = event_format.write_data(data, datacontenttype) + + return KafkaMessage(headers=headers, key=message_key, value=value) + + +def from_binary( + message: KafkaMessage, + event_format: Format, + event_factory: EventFactory | None = None, +) -> BaseCloudEvent: + """ + Parse a Kafka binary content mode message to a CloudEvent. + + Auto-detects the CloudEvents version from the 'ce_specversion' header + and uses the appropriate event factory if not explicitly provided. + + Extracts CloudEvent attributes from ce_-prefixed Kafka headers and treats the + 'content-type' header as the 'datacontenttype' attribute. The Kafka message value + is parsed as event data according to the content type. If the message has a key, + it is added as the 'partitionkey' extension attribute. + + Example: + >>> from cloudevents.core.v1.event import CloudEvent + >>> from cloudevents.core.formats.json import JSONFormat + >>> + >>> message = KafkaMessage( + ... headers={"ce_type": b"com.example.test", "ce_source": b"/test", + ... "ce_id": b"123", "ce_specversion": b"1.0"}, + ... key=b"partition-key-123", + ... value=b'{"message": "Hello"}' + ... ) + >>> event = from_binary(message, JSONFormat(), CloudEvent) + + :param message: KafkaMessage to parse + :param event_format: Format implementation for data deserialization + :param event_factory: Factory function to create CloudEvent instances + :return: CloudEvent instance + """ + attributes: dict[str, Any] = {} + + for header_name, header_value_bytes in message.headers.items(): + header_value = header_value_bytes.decode("utf-8") + + normalized_name = header_name.lower() + + if normalized_name.startswith(CE_PREFIX): + attr_name = normalized_name[len(CE_PREFIX) :] + if attr_name == TIME_ATTR: + attributes[attr_name] = isoparse(header_value) + else: + attributes[attr_name] = header_value + elif normalized_name == CONTENT_TYPE_HEADER: + attributes[DATACONTENTTYPE_ATTR] = header_value + + # If message has a key, add it as partitionkey extension attribute + if message.key is not None: + key_value = ( + message.key.decode("utf-8") + if isinstance(message.key, bytes) + else message.key + ) + attributes[PARTITIONKEY_ATTR] = key_value + + # Auto-detect version if factory not provided + if event_factory is None: + specversion = attributes.get("specversion", SPECVERSION_V1_0) + event_factory = get_event_factory_for_version(specversion) + + datacontenttype = attributes.get(DATACONTENTTYPE_ATTR) + data = event_format.read_data(message.value, datacontenttype) + + return event_factory(attributes, data) + + +def to_structured( + event: BaseCloudEvent, + event_format: Format, + key_mapper: KeyMapper | None = None, +) -> KafkaMessage: + """ + Convert a CloudEvent to Kafka structured content mode. + + In structured mode, the entire CloudEvent (attributes and data) is serialized + into the Kafka message value using the specified format. The content-type header + is set to the format's media type. The message key is derived from the partitionkey + extension attribute or a custom key_mapper function. + + Example: + >>> from cloudevents.core.v1.event import CloudEvent + >>> from cloudevents.core.formats.json import JSONFormat + >>> + >>> event = CloudEvent( + ... attributes={"type": "com.example.test", "source": "/test"}, + ... data={"message": "Hello"} + ... ) + >>> message = to_structured(event, JSONFormat()) + >>> # message.headers = {"content-type": b"application/cloudevents+json"} + >>> # message.value = b'{"type": "com.example.test", "source": "/test", ...}' + + :param event: The CloudEvent to convert + :param event_format: Format implementation for serialization + :param key_mapper: Optional function to extract message key from event (defaults to partitionkey attribute) + :return: KafkaMessage with structured content in value + """ + content_type = event_format.get_content_type() + + headers = {CONTENT_TYPE_HEADER: content_type.encode("utf-8")} + + value = event_format.write(event) + + if key_mapper is None: + key_mapper = _default_key_mapper + message_key = key_mapper(event) + + return KafkaMessage(headers=headers, key=message_key, value=value) + + +def from_structured( + message: KafkaMessage, + event_format: Format, + event_factory: EventFactory | None = None, +) -> BaseCloudEvent: + """ + Parse a Kafka structured content mode message to a CloudEvent. + + Deserializes the CloudEvent from the Kafka message value using the specified format. + Any ce_-prefixed headers are ignored as the value contains all event metadata. + If the message has a key, it is added as the 'partitionkey' extension attribute. + + If event_factory is not provided, version detection is delegated to the format + implementation, which will auto-detect based on the 'specversion' field. + + Example: + >>> from cloudevents.core.v1.event import CloudEvent + >>> from cloudevents.core.formats.json import JSONFormat + >>> + >>> # Explicit factory + >>> message = KafkaMessage( + ... headers={"content-type": b"application/cloudevents+json"}, + ... key=b"partition-key-123", + ... value=b'{"type": "com.example.test", "source": "/test", ...}' + ... ) + >>> event = from_structured(message, JSONFormat(), CloudEvent) + >>> + >>> # Auto-detect version + >>> event = from_structured(message, JSONFormat()) + + :param message: KafkaMessage to parse + :param event_format: Format implementation for deserialization + :param event_factory: Factory function to create CloudEvent instances. + If None, the format will auto-detect the version. + :return: CloudEvent instance + """ + # Delegate version detection to format layer + event = event_format.read(event_factory, message.value) + + # If message has a key, we need to add it as partitionkey extension attribute + # Since the event is already created, we need to reconstruct it with the additional attribute + if message.key is not None: + key_value = ( + message.key.decode("utf-8") + if isinstance(message.key, bytes) + else message.key + ) + attributes = event.get_attributes() + attributes[PARTITIONKEY_ATTR] = key_value + data = event.get_data() + + event = type(event)(attributes, data) + + return event + + +def from_kafka( + message: KafkaMessage, + event_format: Format, + event_factory: EventFactory | None = None, +) -> BaseCloudEvent: + """ + Parse a Kafka message to a CloudEvent with automatic mode detection. + + Auto-detects CloudEvents version and uses appropriate event factory if not provided. + + Automatically detects whether the message uses binary or structured content mode: + - If any ce_ prefixed headers are present → binary mode + - Otherwise → structured mode + + This function provides a convenient way to handle both content modes without + requiring the caller to determine the mode beforehand. + + Example: + >>> from cloudevents.core.v1.event import CloudEvent + >>> from cloudevents.core.formats.json import JSONFormat + >>> + >>> # Works with binary mode + >>> binary_msg = KafkaMessage( + ... headers={"ce_type": b"com.example.test", ...}, + ... key=None, + ... value=b'...' + ... ) + >>> event1 = from_kafka(binary_msg, JSONFormat(), CloudEvent) + >>> + >>> # Also works with structured mode + >>> structured_msg = KafkaMessage( + ... headers={"content-type": b"application/cloudevents+json"}, + ... key=None, + ... value=b'{"type": "com.example.test", ...}' + ... ) + >>> event2 = from_kafka(structured_msg, JSONFormat(), CloudEvent) + + :param message: KafkaMessage to parse + :param event_format: Format implementation for deserialization + :param event_factory: Factory function to create CloudEvent instances (auto-detected if None) + :return: CloudEvent instance + """ + for header_name in message.headers.keys(): + if header_name.lower().startswith(CE_PREFIX): + return from_binary(message, event_format, event_factory) + + return from_structured(message, event_format, event_factory) + + +def to_binary_event( + event: BaseCloudEvent, + event_format: Format | None = None, + key_mapper: KeyMapper | None = None, +) -> KafkaMessage: + """ + Convenience wrapper for to_binary with JSON format and CloudEvent as defaults. + + Example: + >>> from cloudevents.core.v1.event import CloudEvent + >>> from cloudevents.core.bindings import kafka + >>> + >>> event = CloudEvent( + ... attributes={"type": "com.example.test", "source": "/test"}, + ... data={"message": "Hello"} + ... ) + >>> message = kafka.to_binary_event(event) + + :param event: The CloudEvent to convert + :param event_format: Format implementation (defaults to JSONFormat) + :param key_mapper: Optional function to extract message key from event + :return: KafkaMessage with ce_-prefixed headers + """ + if event_format is None: + event_format = JSONFormat() + return to_binary(event, event_format, key_mapper) + + +def from_binary_event( + message: KafkaMessage, + event_format: Format | None = None, +) -> BaseCloudEvent: + """ + Convenience wrapper for from_binary with JSON format and auto-detection. + + Auto-detects CloudEvents version (v0.3 or v1.0) from headers. + + Example: + >>> from cloudevents.core.bindings import kafka + >>> event = kafka.from_binary_event(message) + + :param message: KafkaMessage to parse + :param event_format: Format implementation (defaults to JSONFormat) + :return: CloudEvent instance (v0.3 or v1.0 based on specversion) + """ + if event_format is None: + event_format = JSONFormat() + return from_binary(message, event_format, None) + + +def to_structured_event( + event: BaseCloudEvent, + event_format: Format | None = None, + key_mapper: KeyMapper | None = None, +) -> KafkaMessage: + """ + Convenience wrapper for to_structured with JSON format as default. + + Example: + >>> from cloudevents.core.v1.event import CloudEvent + >>> from cloudevents.core.bindings import kafka + >>> + >>> event = CloudEvent( + ... attributes={"type": "com.example.test", "source": "/test"}, + ... data={"message": "Hello"} + ... ) + >>> message = kafka.to_structured_event(event) + + :param event: The CloudEvent to convert + :param event_format: Format implementation (defaults to JSONFormat) + :param key_mapper: Optional function to extract message key from event + :return: KafkaMessage with structured content + """ + if event_format is None: + event_format = JSONFormat() + return to_structured(event, event_format, key_mapper) + + +def from_structured_event( + message: KafkaMessage, + event_format: Format | None = None, +) -> BaseCloudEvent: + """ + Convenience wrapper for from_structured with JSON format and auto-detection. + + Auto-detects CloudEvents version (v0.3 or v1.0) from message body. + + Example: + >>> from cloudevents.core.bindings import kafka + >>> event = kafka.from_structured_event(message) + + :param message: KafkaMessage to parse + :param event_format: Format implementation (defaults to JSONFormat) + :return: CloudEvent instance (v0.3 or v1.0 based on specversion) + """ + if event_format is None: + event_format = JSONFormat() + return from_structured(message, event_format, None) + + +def from_kafka_event( + message: KafkaMessage, + event_format: Format | None = None, +) -> BaseCloudEvent: + """ + Convenience wrapper for from_kafka with JSON format and auto-detection. + Auto-detects binary or structured mode, and CloudEvents version. + + Example: + >>> from cloudevents.core.bindings import kafka + >>> event = kafka.from_kafka_event(message) + + :param message: KafkaMessage to parse + :param event_format: Format implementation (defaults to JSONFormat) + :return: CloudEvent instance (v0.3 or v1.0 based on specversion) + """ + if event_format is None: + event_format = JSONFormat() + return from_kafka(message, event_format, None) diff --git a/src/cloudevents/core/exceptions.py b/src/cloudevents/core/exceptions.py new file mode 100644 index 00000000..c4a186c4 --- /dev/null +++ b/src/cloudevents/core/exceptions.py @@ -0,0 +1,81 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +""" +Common exceptions for CloudEvents (version-agnostic). +""" + + +class BaseCloudEventException(Exception): + """A CloudEvent generic exception.""" + + +class CloudEventValidationError(BaseCloudEventException): + """ + Holds validation errors aggregated during a CloudEvent creation. + """ + + def __init__(self, errors: dict[str, list[BaseCloudEventException]]) -> None: + """ + :param errors: The errors gathered during the CloudEvent creation where key + is the name of the attribute and value is a list of errors related to that attribute. + """ + super().__init__("Failed to create CloudEvent due to the validation errors:") + self.errors: dict[str, list[BaseCloudEventException]] = errors + + def __str__(self) -> str: + error_messages: list[str] = [ + f"{key}: {', '.join(str(e) for e in value)}" + for key, value in self.errors.items() + ] + return f"{super().__str__()}: {', '.join(error_messages)}" + + +class MissingRequiredAttributeError(BaseCloudEventException, ValueError): + """ + Raised for attributes that are required to be present by the specification. + """ + + def __init__(self, attribute_name: str) -> None: + self.attribute_name: str = attribute_name + super().__init__(f"Missing required attribute: '{attribute_name}'") + + +class CustomExtensionAttributeError(BaseCloudEventException, ValueError): + """ + Raised when a custom extension attribute violates naming conventions. + """ + + def __init__(self, attribute_name: str, msg: str) -> None: + self.attribute_name: str = attribute_name + super().__init__(msg) + + +class InvalidAttributeTypeError(BaseCloudEventException, TypeError): + """ + Raised when an attribute has an unsupported type. + """ + + def __init__(self, attribute_name: str, expected_type: type) -> None: + self.attribute_name: str = attribute_name + super().__init__(f"Attribute '{attribute_name}' must be a {expected_type}") + + +class InvalidAttributeValueError(BaseCloudEventException, ValueError): + """ + Raised when an attribute has an invalid value. + """ + + def __init__(self, attribute_name: str, msg: str) -> None: + self.attribute_name: str = attribute_name + super().__init__(msg) diff --git a/cloudevents/sdk/__init__.py b/src/cloudevents/core/formats/__init__.py similarity index 100% rename from cloudevents/sdk/__init__.py rename to src/cloudevents/core/formats/__init__.py diff --git a/src/cloudevents/core/formats/base.py b/src/cloudevents/core/formats/base.py new file mode 100644 index 00000000..ae2d9d0a --- /dev/null +++ b/src/cloudevents/core/formats/base.py @@ -0,0 +1,90 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from typing import Any, Protocol + +from cloudevents.core.base import BaseCloudEvent, EventFactory + + +class Format(Protocol): + """ + Protocol defining the contract for CloudEvent format implementations. + + Format implementations are responsible for serializing and deserializing CloudEvents + to and from specific wire formats (e.g., JSON, Avro, Protobuf). Each format must + implement both read and write operations to convert between CloudEvent objects and + their byte representations according to the CloudEvents specification. + """ + + def read( + self, + event_factory: EventFactory | None, + data: str | bytes, + ) -> BaseCloudEvent: + """ + Deserialize a CloudEvent from its wire format representation. + + :param event_factory: A factory function that creates CloudEvent instances from + attributes and data. The factory should accept a dictionary of attributes and + optional event data (dict, str, or bytes). + If None, the format implementation should auto-detect the version from the data. + :param data: The serialized CloudEvent data as a string or bytes. + :return: A CloudEvent instance constructed from the deserialized data. + :raises ValueError: If the data cannot be parsed or is invalid according to the format. + """ + ... + + def write(self, event: BaseCloudEvent) -> bytes: + """ + Serialize a CloudEvent to its wire format representation. + + :param event: The CloudEvent instance to serialize. + :return: The CloudEvent serialized as bytes in the format's wire representation. + :raises ValueError: If the event cannot be serialized according to the format. + """ + ... + + def write_data( + self, + data: dict[str, Any] | str | bytes | None, + datacontenttype: str | None, + ) -> bytes: + """ + Serialize just the data payload for protocol bindings (e.g., HTTP binary mode). + + :param data: Event data to serialize (dict, str, bytes, or None) + :param datacontenttype: Content type of the data + :return: Serialized data as bytes + """ + ... + + def read_data( + self, body: bytes, datacontenttype: str | None + ) -> dict[str, Any] | str | bytes | None: + """ + Deserialize data payload from protocol bindings (e.g., HTTP binary mode). + + :param body: HTTP body as bytes + :param datacontenttype: Content type of the data + :return: Deserialized data (dict for JSON, str for text, bytes for binary) + """ + ... + + def get_content_type(self) -> str: + """ + Get the Content-Type header value for structured mode. + + :return: Content type string for CloudEvents structured content mode + """ + ... diff --git a/src/cloudevents/core/formats/json.py b/src/cloudevents/core/formats/json.py new file mode 100644 index 00000000..9ac0e44a --- /dev/null +++ b/src/cloudevents/core/formats/json.py @@ -0,0 +1,223 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import base64 +import re +from datetime import datetime +from json import JSONEncoder, dumps, loads +from typing import Any, Final, Pattern + +from dateutil.parser import isoparse + +from cloudevents.core.base import BaseCloudEvent, EventFactory +from cloudevents.core.formats.base import Format +from cloudevents.core.spec import SPECVERSION_V0_3, SPECVERSION_V1_0 + + +class _JSONEncoderWithDatetime(JSONEncoder): + """ + Custom JSON encoder to handle datetime objects in the format required by the CloudEvents spec. + """ + + def default(self, obj: Any) -> Any: + if isinstance(obj, datetime): + dt = obj.isoformat() + # 'Z' denotes a UTC offset of 00:00 see + # https://www.rfc-editor.org/rfc/rfc3339#section-2 + if dt.endswith("+00:00"): + dt = dt.removesuffix("+00:00") + "Z" + return dt + + return super().default(obj) + + +class JSONFormat(Format): + CONTENT_TYPE: Final[str] = "application/cloudevents+json" + JSON_CONTENT_TYPE_PATTERN: Pattern[str] = re.compile( + r"^(application|text)/([a-zA-Z0-9\-\.]+\+)?json(;.*)?$" + ) + + def read( + self, + event_factory: EventFactory | None, + data: str | bytes, + ) -> BaseCloudEvent: + """ + Read a CloudEvent from a JSON formatted byte string. + + Supports both v0.3 and v1.0 CloudEvents: + - v0.3: Uses 'datacontentencoding' attribute with 'data' field + - v1.0: Uses 'data_base64' field (no datacontentencoding) + + :param event_factory: A factory function to create CloudEvent instances. + If None, automatically detects version from 'specversion' field. + :param data: The JSON formatted byte array. + :return: The CloudEvent instance. + """ + decoded_data: str + if isinstance(data, bytes): + decoded_data = data.decode("utf-8") + else: + decoded_data = data + + event_attributes = loads(decoded_data) + + # Auto-detect version if factory not provided + if event_factory is None: + from cloudevents.core.bindings.common import get_event_factory_for_version + + specversion = event_attributes.get("specversion", SPECVERSION_V1_0) + event_factory = get_event_factory_for_version(specversion) + + if "time" in event_attributes: + event_attributes["time"] = isoparse(event_attributes["time"]) + + # Handle data field based on version + specversion = event_attributes.get("specversion", SPECVERSION_V1_0) + event_data: dict[str, Any] | str | bytes | None = event_attributes.pop( + "data", None + ) + + # v0.3: Check for datacontentencoding attribute + if ( + specversion == SPECVERSION_V0_3 + and "datacontentencoding" in event_attributes + ): + encoding = event_attributes.get("datacontentencoding", "").lower() + if encoding == "base64" and isinstance(event_data, str): + # Decode base64 encoded data in v0.3 + event_data = base64.b64decode(event_data) + + # v1.0: Check for data_base64 field (when data is None) + if event_data is None: + event_data_base64 = event_attributes.pop("data_base64", None) + if event_data_base64 is not None: + event_data = base64.b64decode(event_data_base64) + + return event_factory(event_attributes, event_data) + + def write(self, event: BaseCloudEvent) -> bytes: + """ + Write a CloudEvent to a JSON formatted byte string. + + Supports both v0.3 and v1.0 CloudEvents: + - v0.3: Uses 'datacontentencoding: base64' with base64-encoded 'data' field + - v1.0: Uses 'data_base64' field (no datacontentencoding) + + :param event: The CloudEvent to write. + :return: The CloudEvent as a JSON formatted byte array. + """ + event_data = event.get_data() + event_dict: dict[str, Any] = dict(event.get_attributes()) + specversion = event_dict.get("specversion", SPECVERSION_V1_0) + + if event_data is not None: + if isinstance(event_data, (bytes, bytearray)): + # Handle binary data based on version + if specversion == SPECVERSION_V0_3: + # v0.3: Use datacontentencoding with base64-encoded data field + event_dict["datacontentencoding"] = "base64" + event_dict["data"] = base64.b64encode(event_data).decode("utf-8") + else: + # v1.0: Use data_base64 field + event_dict["data_base64"] = base64.b64encode(event_data).decode( + "utf-8" + ) + else: + datacontenttype = event_dict.get("datacontenttype", "application/json") + if re.match(JSONFormat.JSON_CONTENT_TYPE_PATTERN, datacontenttype): + event_dict["data"] = event_data + else: + event_dict["data"] = str(event_data) + + return dumps(event_dict, cls=_JSONEncoderWithDatetime).encode("utf-8") + + def write_data( + self, + data: dict[str, Any] | str | bytes | None, + datacontenttype: str | None, + ) -> bytes: + """ + Serialize just the data payload for HTTP binary mode. + + This method is used by HTTP binary content mode to serialize only the event + data (not the attributes) into the HTTP body. + + :param data: Event data to serialize (dict, str, bytes, or None) + :param datacontenttype: Content type of the data + :return: Serialized data as bytes + """ + if data is None: + return b"" + + # If data is already bytes, return as-is + if isinstance(data, (bytes, bytearray)): + return bytes(data) + + # If data is a string, encode as UTF-8 + if isinstance(data, str): + return data.encode("utf-8") + + # If data is a dict and content type is JSON, serialize as JSON + if isinstance(data, dict): + if datacontenttype and re.match( + JSONFormat.JSON_CONTENT_TYPE_PATTERN, datacontenttype + ): + return dumps(data, cls=_JSONEncoderWithDatetime).encode("utf-8") + + # Default: convert to string and encode + return str(data).encode("utf-8") + + def read_data( + self, body: bytes, datacontenttype: str | None + ) -> dict[str, Any] | str | bytes | None: + """ + Deserialize data payload from HTTP binary mode body. + + This method is used by HTTP binary content mode to deserialize the HTTP body + into event data based on the content type. + + :param body: HTTP body as bytes + :param datacontenttype: Content type of the data + :return: Deserialized data (dict for JSON, str for text, bytes for binary) + """ + if not body or len(body) == 0: + return None + + # If content type indicates JSON, try to parse as JSON + if datacontenttype and re.match( + JSONFormat.JSON_CONTENT_TYPE_PATTERN, datacontenttype + ): + try: + decoded = body.decode("utf-8") + parsed: dict[str, Any] = loads(decoded) + return parsed + except (ValueError, UnicodeDecodeError): + # If JSON parsing fails, fall through to other handling + pass + + # Try to decode as UTF-8 string + try: + return body.decode("utf-8") + except UnicodeDecodeError: + # If UTF-8 decoding fails, return as bytes + return body + + def get_content_type(self) -> str: + """ + Get the Content-Type header value for structured mode. + + :return: Content type string for CloudEvents structured content mode + """ + return self.CONTENT_TYPE diff --git a/src/cloudevents/core/spec.py b/src/cloudevents/core/spec.py new file mode 100644 index 00000000..e3858189 --- /dev/null +++ b/src/cloudevents/core/spec.py @@ -0,0 +1,18 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +from typing import Literal + +SpecVersion = Literal["1.0", "0.3"] +SPECVERSION_V1_0 = "1.0" +SPECVERSION_V0_3 = "0.3" diff --git a/src/cloudevents/core/v03/__init__.py b/src/cloudevents/core/v03/__init__.py new file mode 100644 index 00000000..67b5e010 --- /dev/null +++ b/src/cloudevents/core/v03/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""CloudEvents v0.3 implementation module.""" diff --git a/src/cloudevents/core/v03/event.py b/src/cloudevents/core/v03/event.py new file mode 100644 index 00000000..4d51c3ed --- /dev/null +++ b/src/cloudevents/core/v03/event.py @@ -0,0 +1,341 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import re +import uuid +from collections import defaultdict +from datetime import datetime, timezone +from typing import Any, Final + +from cloudevents.core.base import BaseCloudEvent +from cloudevents.core.exceptions import ( + BaseCloudEventException, + CloudEventValidationError, + CustomExtensionAttributeError, + InvalidAttributeTypeError, + InvalidAttributeValueError, + MissingRequiredAttributeError, +) +from cloudevents.core.spec import SPECVERSION_V0_3 + +REQUIRED_ATTRIBUTES: Final[list[str]] = ["id", "source", "type", "specversion"] +OPTIONAL_ATTRIBUTES: Final[list[str]] = [ + "datacontenttype", + "datacontentencoding", + "schemaurl", + "subject", + "time", +] + + +class CloudEvent(BaseCloudEvent): + """ + CloudEvents v0.3 implementation. + + This class represents a CloudEvent conforming to the v0.3 specification. + See https://github.com/cloudevents/spec/blob/v0.3/spec.md for details. + """ + + def __init__( + self, + attributes: dict[str, Any], + data: dict[str, Any] | str | bytes | None = None, + ) -> None: + """ + :param attributes: The attributes of the CloudEvent instance. + If not provided, ``specversion`` defaults to ``"0.3"``, + ``id`` to a UUID4, and ``time`` to the current UTC timestamp. + :param data: The payload of the CloudEvent instance. + :raises CloudEventValidationError: If any of the required attributes + are missing or have invalid values. + """ + if "specversion" not in attributes: + attributes["specversion"] = SPECVERSION_V0_3 + if "id" not in attributes: + attributes["id"] = str(uuid.uuid4()) + if "time" not in attributes: + attributes["time"] = datetime.now(timezone.utc) + + self._validate_attribute(attributes=attributes) + self._attributes: dict[str, Any] = attributes + self._data: dict[str, Any] | str | bytes | None = data + + @staticmethod + def _validate_attribute(attributes: dict[str, Any]) -> None: + """ + Validates the attributes of the CloudEvent as per the CloudEvents v0.3 specification. + + See https://github.com/cloudevents/spec/blob/v0.3/spec.md#required-attributes + """ + errors: dict[str, list[BaseCloudEventException]] = defaultdict(list) + errors.update(CloudEvent._validate_required_attributes(attributes=attributes)) + errors.update(CloudEvent._validate_optional_attributes(attributes=attributes)) + errors.update(CloudEvent._validate_extension_attributes(attributes=attributes)) + if errors: + raise CloudEventValidationError(errors=errors) + + @staticmethod + def _validate_required_attributes( + attributes: dict[str, Any], + ) -> dict[str, list[BaseCloudEventException]]: + """ + Validates the types of the required attributes. + + :param attributes: The attributes of the CloudEvent instance. + :return: A dictionary of validation error messages. + """ + errors: dict[str, list[BaseCloudEventException]] = defaultdict(list) + + if "id" not in attributes: + errors["id"].append(MissingRequiredAttributeError(attribute_name="id")) + if not attributes.get("id"): + errors["id"].append( + InvalidAttributeValueError( + attribute_name="id", msg="Attribute 'id' must not be None or empty" + ) + ) + if not isinstance(attributes.get("id"), str): + errors["id"].append( + InvalidAttributeTypeError(attribute_name="id", expected_type=str) + ) + + if "source" not in attributes: + errors["source"].append( + MissingRequiredAttributeError(attribute_name="source") + ) + if not attributes.get("source"): + errors["source"].append( + InvalidAttributeValueError( + attribute_name="source", + msg="Attribute 'source' must not be None or empty", + ) + ) + if not isinstance(attributes.get("source"), str): + errors["source"].append( + InvalidAttributeTypeError(attribute_name="source", expected_type=str) + ) + + if "type" not in attributes: + errors["type"].append(MissingRequiredAttributeError(attribute_name="type")) + if not attributes.get("type"): + errors["type"].append( + InvalidAttributeValueError( + attribute_name="type", + msg="Attribute 'type' must not be None or empty", + ) + ) + if not isinstance(attributes.get("type"), str): + errors["type"].append( + InvalidAttributeTypeError(attribute_name="type", expected_type=str) + ) + + if "specversion" not in attributes: + errors["specversion"].append( + MissingRequiredAttributeError(attribute_name="specversion") + ) + if not isinstance(attributes.get("specversion"), str): + errors["specversion"].append( + InvalidAttributeTypeError( + attribute_name="specversion", expected_type=str + ) + ) + if attributes.get("specversion") != SPECVERSION_V0_3: + errors["specversion"].append( + InvalidAttributeValueError( + attribute_name="specversion", + msg=f"Attribute 'specversion' must be '{SPECVERSION_V0_3}'", + ) + ) + return errors + + @staticmethod + def _validate_optional_attributes( + attributes: dict[str, Any], + ) -> dict[str, list[BaseCloudEventException]]: + """ + Validates the types and values of the optional attributes. + + :param attributes: The attributes of the CloudEvent instance. + :return: A dictionary of validation error messages. + """ + errors: dict[str, list[BaseCloudEventException]] = defaultdict(list) + + if "time" in attributes: + if not isinstance(attributes["time"], datetime): + errors["time"].append( + InvalidAttributeTypeError( + attribute_name="time", expected_type=datetime + ) + ) + if hasattr(attributes["time"], "tzinfo") and not attributes["time"].tzinfo: + errors["time"].append( + InvalidAttributeValueError( + attribute_name="time", + msg="Attribute 'time' must be timezone aware", + ) + ) + if "subject" in attributes: + if not isinstance(attributes["subject"], str): + errors["subject"].append( + InvalidAttributeTypeError( + attribute_name="subject", expected_type=str + ) + ) + if not attributes["subject"]: + errors["subject"].append( + InvalidAttributeValueError( + attribute_name="subject", + msg="Attribute 'subject' must not be empty", + ) + ) + if "datacontenttype" in attributes: + if not isinstance(attributes["datacontenttype"], str): + errors["datacontenttype"].append( + InvalidAttributeTypeError( + attribute_name="datacontenttype", expected_type=str + ) + ) + if not attributes["datacontenttype"]: + errors["datacontenttype"].append( + InvalidAttributeValueError( + attribute_name="datacontenttype", + msg="Attribute 'datacontenttype' must not be empty", + ) + ) + if "datacontentencoding" in attributes: + if not isinstance(attributes["datacontentencoding"], str): + errors["datacontentencoding"].append( + InvalidAttributeTypeError( + attribute_name="datacontentencoding", expected_type=str + ) + ) + if not attributes["datacontentencoding"]: + errors["datacontentencoding"].append( + InvalidAttributeValueError( + attribute_name="datacontentencoding", + msg="Attribute 'datacontentencoding' must not be empty", + ) + ) + if "schemaurl" in attributes: + if not isinstance(attributes["schemaurl"], str): + errors["schemaurl"].append( + InvalidAttributeTypeError( + attribute_name="schemaurl", expected_type=str + ) + ) + if not attributes["schemaurl"]: + errors["schemaurl"].append( + InvalidAttributeValueError( + attribute_name="schemaurl", + msg="Attribute 'schemaurl' must not be empty", + ) + ) + return errors + + @staticmethod + def _validate_extension_attributes( + attributes: dict[str, Any], + ) -> dict[str, list[BaseCloudEventException]]: + """ + Validates the extension attributes. + + :param attributes: The attributes of the CloudEvent instance. + :return: A dictionary of validation error messages. + """ + errors: dict[str, list[BaseCloudEventException]] = defaultdict(list) + extension_attributes = [ + key + for key in attributes.keys() + if key not in REQUIRED_ATTRIBUTES and key not in OPTIONAL_ATTRIBUTES + ] + for extension_attribute in extension_attributes: + if extension_attribute == "data": + errors[extension_attribute].append( + CustomExtensionAttributeError( + attribute_name=extension_attribute, + msg="Extension attribute 'data' is reserved and must not be used", + ) + ) + if not (1 <= len(extension_attribute)): + errors[extension_attribute].append( + CustomExtensionAttributeError( + attribute_name=extension_attribute, + msg=f"Extension attribute name must be at least 1 character long but was '{extension_attribute}'", + ) + ) + if not re.match(r"^[a-z0-9]+$", extension_attribute): + errors[extension_attribute].append( + CustomExtensionAttributeError( + attribute_name=extension_attribute, + msg=f"Extension attribute '{extension_attribute}' should only contain lowercase letters and numbers", + ) + ) + return errors + + def get_id(self) -> str: + return self._attributes["id"] # type: ignore + + def get_source(self) -> str: + return self._attributes["source"] # type: ignore + + def get_type(self) -> str: + return self._attributes["type"] # type: ignore + + def get_specversion(self) -> str: + return self._attributes["specversion"] # type: ignore + + def get_datacontenttype(self) -> str | None: + return self._attributes.get("datacontenttype") + + def get_dataschema(self) -> str | None: + """ + Get the dataschema attribute. + + Note: In v0.3, this is called 'schemaurl'. This method provides + compatibility with the BaseCloudEvent interface. + """ + return self._attributes.get("schemaurl") + + def get_subject(self) -> str | None: + return self._attributes.get("subject") + + def get_time(self) -> datetime | None: + return self._attributes.get("time") + + def get_extension(self, extension_name: str) -> Any: + return self._attributes.get(extension_name) + + def get_data(self) -> dict[str, Any] | str | bytes | None: + return self._data + + def get_attributes(self) -> dict[str, Any]: + return self._attributes + + # v0.3 specific methods + + def get_datacontentencoding(self) -> str | None: + """ + Get the datacontentencoding attribute (v0.3 only). + + This attribute was removed in v1.0. + """ + return self._attributes.get("datacontentencoding") + + def get_schemaurl(self) -> str | None: + """ + Get the schemaurl attribute (v0.3 only). + + This attribute was renamed to 'dataschema' in v1.0. + """ + return self._attributes.get("schemaurl") diff --git a/src/cloudevents/core/v1/__init__.py b/src/cloudevents/core/v1/__init__.py new file mode 100644 index 00000000..896dfe12 --- /dev/null +++ b/src/cloudevents/core/v1/__init__.py @@ -0,0 +1,17 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +CloudEvent implementation for v1.0 +""" diff --git a/src/cloudevents/core/v1/event.py b/src/cloudevents/core/v1/event.py new file mode 100644 index 00000000..a71a3d58 --- /dev/null +++ b/src/cloudevents/core/v1/event.py @@ -0,0 +1,302 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import re +import uuid +from collections import defaultdict +from datetime import datetime, timezone +from typing import Any, Final + +from cloudevents.core.base import BaseCloudEvent +from cloudevents.core.exceptions import ( + BaseCloudEventException, + CloudEventValidationError, + CustomExtensionAttributeError, + InvalidAttributeTypeError, + InvalidAttributeValueError, + MissingRequiredAttributeError, +) +from cloudevents.core.spec import SPECVERSION_V1_0 + +REQUIRED_ATTRIBUTES: Final[list[str]] = ["id", "source", "type", "specversion"] +OPTIONAL_ATTRIBUTES: Final[list[str]] = [ + "datacontenttype", + "dataschema", + "subject", + "time", +] + + +class CloudEvent(BaseCloudEvent): + """ + CloudEvents v1.0 implementation. + + This class represents a CloudEvent conforming to the v1.0 specification. + See https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/spec.md for details. + """ + + def __init__( + self, + attributes: dict[str, Any], + data: dict[str, Any] | str | bytes | None = None, + ) -> None: + """ + :param attributes: The attributes of the CloudEvent instance. + If not provided, ``specversion`` defaults to ``"1.0"``, + ``id`` to a UUID4, and ``time`` to the current UTC timestamp. + :param data: The payload of the CloudEvent instance. + :raises CloudEventValidationError: If any of the required attributes + are missing or have invalid values. + """ + if "specversion" not in attributes: + attributes["specversion"] = SPECVERSION_V1_0 + if "id" not in attributes: + attributes["id"] = str(uuid.uuid4()) + if "time" not in attributes: + attributes["time"] = datetime.now(timezone.utc) + + self._validate_attribute(attributes=attributes) + self._attributes: dict[str, Any] = attributes + self._data: dict[str, Any] | str | bytes | None = data + + @staticmethod + def _validate_attribute(attributes: dict[str, Any]) -> None: + """ + Validates the attributes of the CloudEvent as per the CloudEvents specification. + + See https://github.com/cloudevents/spec/blob/main/cloudevents/spec.md#required-attributes + """ + errors: dict[str, list[BaseCloudEventException]] = defaultdict(list) + errors.update(CloudEvent._validate_required_attributes(attributes=attributes)) + errors.update(CloudEvent._validate_optional_attributes(attributes=attributes)) + errors.update(CloudEvent._validate_extension_attributes(attributes=attributes)) + if errors: + raise CloudEventValidationError(errors=errors) + + @staticmethod + def _validate_required_attributes( + attributes: dict[str, Any], + ) -> dict[str, list[BaseCloudEventException]]: + """ + Validates the types of the required attributes. + + :param attributes: The attributes of the CloudEvent instance. + :return: A dictionary of validation error messages. + """ + errors: dict[str, list[BaseCloudEventException]] = defaultdict(list) + + if "id" not in attributes: + errors["id"].append(MissingRequiredAttributeError(attribute_name="id")) + if not attributes.get("id"): + errors["id"].append( + InvalidAttributeValueError( + attribute_name="id", msg="Attribute 'id' must not be None or empty" + ) + ) + if not isinstance(attributes.get("id"), str): + errors["id"].append( + InvalidAttributeTypeError(attribute_name="id", expected_type=str) + ) + + if "source" not in attributes: + errors["source"].append( + MissingRequiredAttributeError(attribute_name="source") + ) + if not attributes.get("source"): + errors["source"].append( + InvalidAttributeValueError( + attribute_name="source", + msg="Attribute 'source' must not be None or empty", + ) + ) + if not isinstance(attributes.get("source"), str): + errors["source"].append( + InvalidAttributeTypeError(attribute_name="source", expected_type=str) + ) + + if "type" not in attributes: + errors["type"].append(MissingRequiredAttributeError(attribute_name="type")) + if not attributes.get("type"): + errors["type"].append( + InvalidAttributeValueError( + attribute_name="type", + msg="Attribute 'type' must not be None or empty", + ) + ) + if not isinstance(attributes.get("type"), str): + errors["type"].append( + InvalidAttributeTypeError(attribute_name="type", expected_type=str) + ) + + if "specversion" not in attributes: + errors["specversion"].append( + MissingRequiredAttributeError(attribute_name="specversion") + ) + if not isinstance(attributes.get("specversion"), str): + errors["specversion"].append( + InvalidAttributeTypeError( + attribute_name="specversion", expected_type=str + ) + ) + if attributes.get("specversion") != SPECVERSION_V1_0: + errors["specversion"].append( + InvalidAttributeValueError( + attribute_name="specversion", + msg=f"Attribute 'specversion' must be '{SPECVERSION_V1_0}'", + ) + ) + return errors + + @staticmethod + def _validate_optional_attributes( + attributes: dict[str, Any], + ) -> dict[str, list[BaseCloudEventException]]: + """ + Validates the types and values of the optional attributes. + + :param attributes: The attributes of the CloudEvent instance. + :return: A dictionary of validation error messages. + """ + errors: dict[str, list[BaseCloudEventException]] = defaultdict(list) + + if "time" in attributes: + if not isinstance(attributes["time"], datetime): + errors["time"].append( + InvalidAttributeTypeError( + attribute_name="time", expected_type=datetime + ) + ) + if hasattr(attributes["time"], "tzinfo") and not attributes["time"].tzinfo: + errors["time"].append( + InvalidAttributeValueError( + attribute_name="time", + msg="Attribute 'time' must be timezone aware", + ) + ) + if "subject" in attributes: + if not isinstance(attributes["subject"], str): + errors["subject"].append( + InvalidAttributeTypeError( + attribute_name="subject", expected_type=str + ) + ) + if not attributes["subject"]: + errors["subject"].append( + InvalidAttributeValueError( + attribute_name="subject", + msg="Attribute 'subject' must not be empty", + ) + ) + if "datacontenttype" in attributes: + if not isinstance(attributes["datacontenttype"], str): + errors["datacontenttype"].append( + InvalidAttributeTypeError( + attribute_name="datacontenttype", expected_type=str + ) + ) + if not attributes["datacontenttype"]: + errors["datacontenttype"].append( + InvalidAttributeValueError( + attribute_name="datacontenttype", + msg="Attribute 'datacontenttype' must not be empty", + ) + ) + if "dataschema" in attributes: + if not isinstance(attributes["dataschema"], str): + errors["dataschema"].append( + InvalidAttributeTypeError( + attribute_name="dataschema", expected_type=str + ) + ) + if not attributes["dataschema"]: + errors["dataschema"].append( + InvalidAttributeValueError( + attribute_name="dataschema", + msg="Attribute 'dataschema' must not be empty", + ) + ) + return errors + + @staticmethod + def _validate_extension_attributes( + attributes: dict[str, Any], + ) -> dict[str, list[BaseCloudEventException]]: + """ + Validates the extension attributes. + + :param attributes: The attributes of the CloudEvent instance. + :return: A dictionary of validation error messages. + """ + errors: dict[str, list[BaseCloudEventException]] = defaultdict(list) + extension_attributes = [ + key + for key in attributes.keys() + if key not in REQUIRED_ATTRIBUTES and key not in OPTIONAL_ATTRIBUTES + ] + for extension_attribute in extension_attributes: + if extension_attribute == "data": + errors[extension_attribute].append( + CustomExtensionAttributeError( + attribute_name=extension_attribute, + msg="Extension attribute 'data' is reserved and must not be used", + ) + ) + if not (1 <= len(extension_attribute)): + errors[extension_attribute].append( + CustomExtensionAttributeError( + attribute_name=extension_attribute, + msg=f"Extension attribute name must be at least 1 character long but was '{extension_attribute}'", + ) + ) + if not re.match(r"^[a-z0-9]+$", extension_attribute): + errors[extension_attribute].append( + CustomExtensionAttributeError( + attribute_name=extension_attribute, + msg=f"Extension attribute '{extension_attribute}' should only contain lowercase letters and numbers", + ) + ) + return errors + + def get_id(self) -> str: + return self._attributes["id"] # type: ignore + + def get_source(self) -> str: + return self._attributes["source"] # type: ignore + + def get_type(self) -> str: + return self._attributes["type"] # type: ignore + + def get_specversion(self) -> str: + return self._attributes["specversion"] # type: ignore + + def get_datacontenttype(self) -> str | None: + return self._attributes.get("datacontenttype") + + def get_dataschema(self) -> str | None: + return self._attributes.get("dataschema") + + def get_subject(self) -> str | None: + return self._attributes.get("subject") + + def get_time(self) -> datetime | None: + return self._attributes.get("time") + + def get_extension(self, extension_name: str) -> Any: + return self._attributes.get(extension_name) + + def get_data(self) -> dict[str, Any] | str | bytes | None: + return self._data + + def get_attributes(self) -> dict[str, Any]: + return self._attributes diff --git a/cloudevents/py.typed b/src/cloudevents/py.typed similarity index 100% rename from cloudevents/py.typed rename to src/cloudevents/py.typed diff --git a/cloudevents/__init__.py b/src/cloudevents/v1/__init__.py similarity index 100% rename from cloudevents/__init__.py rename to src/cloudevents/v1/__init__.py diff --git a/cloudevents/abstract/__init__.py b/src/cloudevents/v1/abstract/__init__.py similarity index 90% rename from cloudevents/abstract/__init__.py rename to src/cloudevents/v1/abstract/__init__.py index 4000c8a7..9f4822d7 100644 --- a/cloudevents/abstract/__init__.py +++ b/src/cloudevents/v1/abstract/__init__.py @@ -12,6 +12,6 @@ # License for the specific language governing permissions and limitations # under the License. -from cloudevents.abstract.event import AnyCloudEvent, CloudEvent +from cloudevents.v1.abstract.event import AnyCloudEvent, CloudEvent __all__ = ["AnyCloudEvent", "CloudEvent"] diff --git a/cloudevents/abstract/event.py b/src/cloudevents/v1/abstract/event.py similarity index 100% rename from cloudevents/abstract/event.py rename to src/cloudevents/v1/abstract/event.py diff --git a/cloudevents/conversion.py b/src/cloudevents/v1/conversion.py similarity index 97% rename from cloudevents/conversion.py rename to src/cloudevents/v1/conversion.py index 6b83cfe8..51cb3fa6 100644 --- a/cloudevents/conversion.py +++ b/src/cloudevents/v1/conversion.py @@ -16,11 +16,11 @@ import json import typing -from cloudevents import exceptions as cloud_exceptions -from cloudevents.abstract import AnyCloudEvent -from cloudevents.sdk import converters, marshaller, types -from cloudevents.sdk.converters import is_binary -from cloudevents.sdk.event import v1, v03 +from cloudevents.v1 import exceptions as cloud_exceptions +from cloudevents.v1.abstract import AnyCloudEvent +from cloudevents.v1.sdk import converters, marshaller, types +from cloudevents.v1.sdk.converters import is_binary +from cloudevents.v1.sdk.event import v03, v1 def _best_effort_serialize_to_json( # type: ignore[no-untyped-def] diff --git a/cloudevents/exceptions.py b/src/cloudevents/v1/exceptions.py similarity index 100% rename from cloudevents/exceptions.py rename to src/cloudevents/v1/exceptions.py diff --git a/cloudevents/http/__init__.py b/src/cloudevents/v1/http/__init__.py similarity index 73% rename from cloudevents/http/__init__.py rename to src/cloudevents/v1/http/__init__.py index 6e75636e..ba276b7d 100644 --- a/cloudevents/http/__init__.py +++ b/src/cloudevents/v1/http/__init__.py @@ -13,16 +13,16 @@ # under the License. -from cloudevents.http.conversion import from_dict, from_http, from_json -from cloudevents.http.event import CloudEvent -from cloudevents.http.event_type import is_binary, is_structured # deprecated -from cloudevents.http.http_methods import ( # deprecated +from cloudevents.v1.http.conversion import from_dict, from_http, from_json +from cloudevents.v1.http.event import CloudEvent +from cloudevents.v1.http.event_type import is_binary, is_structured # deprecated +from cloudevents.v1.http.http_methods import ( # deprecated to_binary, to_binary_http, to_structured, to_structured_http, ) -from cloudevents.http.json_methods import to_json # deprecated +from cloudevents.v1.http.json_methods import to_json # deprecated __all__ = [ "to_binary", diff --git a/cloudevents/http/conversion.py b/src/cloudevents/v1/http/conversion.py similarity index 88% rename from cloudevents/http/conversion.py rename to src/cloudevents/v1/http/conversion.py index 13955ea4..7a2acc1b 100644 --- a/cloudevents/http/conversion.py +++ b/src/cloudevents/v1/http/conversion.py @@ -14,11 +14,11 @@ import typing -from cloudevents.conversion import from_dict as _abstract_from_dict -from cloudevents.conversion import from_http as _abstract_from_http -from cloudevents.conversion import from_json as _abstract_from_json -from cloudevents.http.event import CloudEvent -from cloudevents.sdk import types +from cloudevents.v1.conversion import from_dict as _abstract_from_dict +from cloudevents.v1.conversion import from_http as _abstract_from_http +from cloudevents.v1.conversion import from_json as _abstract_from_json +from cloudevents.v1.http.event import CloudEvent +from cloudevents.v1.sdk import types def from_json( diff --git a/cloudevents/http/event.py b/src/cloudevents/v1/http/event.py similarity index 96% rename from cloudevents/http/event.py rename to src/cloudevents/v1/http/event.py index f3c00638..377262d5 100644 --- a/cloudevents/http/event.py +++ b/src/cloudevents/v1/http/event.py @@ -16,9 +16,9 @@ import typing import uuid -import cloudevents.exceptions as cloud_exceptions -from cloudevents import abstract -from cloudevents.sdk.event import v1, v03 +import cloudevents.v1.exceptions as cloud_exceptions +from cloudevents.v1 import abstract +from cloudevents.v1.sdk.event import v03, v1 _required_by_version = { "1.0": v1.Event._ce_required_fields, diff --git a/cloudevents/http/event_type.py b/src/cloudevents/v1/http/event_type.py similarity index 88% rename from cloudevents/http/event_type.py rename to src/cloudevents/v1/http/event_type.py index 52259e1e..8e375411 100644 --- a/cloudevents/http/event_type.py +++ b/src/cloudevents/v1/http/event_type.py @@ -15,8 +15,8 @@ from deprecation import deprecated -from cloudevents.sdk.converters import is_binary as _moved_is_binary -from cloudevents.sdk.converters import is_structured as _moved_is_structured +from cloudevents.v1.sdk.converters import is_binary as _moved_is_binary +from cloudevents.v1.sdk.converters import is_structured as _moved_is_structured # THIS MODULE IS DEPRECATED, YOU SHOULD NOT ADD NEW FUNCTIONALLY HERE diff --git a/cloudevents/http/http_methods.py b/src/cloudevents/v1/http/http_methods.py similarity index 86% rename from cloudevents/http/http_methods.py rename to src/cloudevents/v1/http/http_methods.py index 091c51b5..1f115dd5 100644 --- a/cloudevents/http/http_methods.py +++ b/src/cloudevents/v1/http/http_methods.py @@ -16,12 +16,12 @@ from deprecation import deprecated -from cloudevents.abstract import AnyCloudEvent -from cloudevents.conversion import to_binary as _moved_to_binary -from cloudevents.conversion import to_structured as _moved_to_structured -from cloudevents.http.conversion import from_http as _moved_from_http -from cloudevents.http.event import CloudEvent -from cloudevents.sdk import types +from cloudevents.v1.abstract import AnyCloudEvent +from cloudevents.v1.conversion import to_binary as _moved_to_binary +from cloudevents.v1.conversion import to_structured as _moved_to_structured +from cloudevents.v1.http.conversion import from_http as _moved_from_http +from cloudevents.v1.http.event import CloudEvent +from cloudevents.v1.sdk import types # THIS MODULE IS DEPRECATED, YOU SHOULD NOT ADD NEW FUNCTIONALLY HERE diff --git a/cloudevents/http/json_methods.py b/src/cloudevents/v1/http/json_methods.py similarity index 83% rename from cloudevents/http/json_methods.py rename to src/cloudevents/v1/http/json_methods.py index 58e322c7..0ed67dad 100644 --- a/cloudevents/http/json_methods.py +++ b/src/cloudevents/v1/http/json_methods.py @@ -16,11 +16,11 @@ from deprecation import deprecated -from cloudevents.abstract import AnyCloudEvent -from cloudevents.conversion import to_json as _moved_to_json -from cloudevents.http import CloudEvent -from cloudevents.http.conversion import from_json as _moved_from_json -from cloudevents.sdk import types +from cloudevents.v1.abstract import AnyCloudEvent +from cloudevents.v1.conversion import to_json as _moved_to_json +from cloudevents.v1.http import CloudEvent +from cloudevents.v1.http.conversion import from_json as _moved_from_json +from cloudevents.v1.sdk import types # THIS MODULE IS DEPRECATED, YOU SHOULD NOT ADD NEW FUNCTIONALLY HERE diff --git a/cloudevents/http/util.py b/src/cloudevents/v1/http/util.py similarity index 96% rename from cloudevents/http/util.py rename to src/cloudevents/v1/http/util.py index f44395e6..1cdb4039 100644 --- a/cloudevents/http/util.py +++ b/src/cloudevents/v1/http/util.py @@ -15,7 +15,7 @@ from deprecation import deprecated -from cloudevents.conversion import ( +from cloudevents.v1.conversion import ( _best_effort_serialize_to_json as _moved_default_marshaller, ) diff --git a/cloudevents/kafka/__init__.py b/src/cloudevents/v1/kafka/__init__.py similarity index 94% rename from cloudevents/kafka/__init__.py rename to src/cloudevents/v1/kafka/__init__.py index fbe1dfb0..81cb7385 100644 --- a/cloudevents/kafka/__init__.py +++ b/src/cloudevents/v1/kafka/__init__.py @@ -12,7 +12,7 @@ # License for the specific language governing permissions and limitations # under the License. -from cloudevents.kafka.conversion import ( +from cloudevents.v1.kafka.conversion import ( KafkaMessage, KeyMapper, from_binary, diff --git a/cloudevents/kafka/conversion.py b/src/cloudevents/v1/kafka/conversion.py similarity index 95% rename from cloudevents/kafka/conversion.py rename to src/cloudevents/v1/kafka/conversion.py index bdf2acab..6497dbc7 100644 --- a/cloudevents/kafka/conversion.py +++ b/src/cloudevents/v1/kafka/conversion.py @@ -15,11 +15,11 @@ import json import typing -from cloudevents import exceptions as cloud_exceptions -from cloudevents import http -from cloudevents.abstract import AnyCloudEvent -from cloudevents.kafka.exceptions import KeyMapperError -from cloudevents.sdk import types +from cloudevents.v1 import exceptions as cloud_exceptions +from cloudevents.v1 import http +from cloudevents.v1.abstract import AnyCloudEvent +from cloudevents.v1.kafka.exceptions import KeyMapperError +from cloudevents.v1.sdk import types JSON_MARSHALLER: types.MarshallerType = json.dumps JSON_UNMARSHALLER: types.UnmarshallerType = json.loads @@ -207,7 +207,7 @@ def to_structured( raise cloud_exceptions.DataMarshallerError( f"Failed to marshall data with error: {type(e).__name__}('{e}')" ) - if isinstance(data, (bytes, bytes, memoryview)): + if isinstance(data, (bytes, bytearray, memoryview)): attrs["data_base64"] = base64.b64encode(data).decode("ascii") else: attrs["data"] = data @@ -272,7 +272,7 @@ def from_structured( structure = envelope_unmarshaller(message.value) except Exception as e: raise cloud_exceptions.DataUnmarshallerError( - "Failed to unmarshall message with error: " f"{type(e).__name__}('{e}')" + f"Failed to unmarshall message with error: {type(e).__name__}('{e}')" ) attributes: typing.Dict[str, typing.Any] = {} @@ -291,7 +291,7 @@ def from_structured( decoded_value = value except Exception as e: raise cloud_exceptions.DataUnmarshallerError( - "Failed to unmarshall data with error: " f"{type(e).__name__}('{e}')" + f"Failed to unmarshall data with error: {type(e).__name__}('{e}')" ) if name == "data": data = decoded_value diff --git a/cloudevents/kafka/exceptions.py b/src/cloudevents/v1/kafka/exceptions.py similarity index 92% rename from cloudevents/kafka/exceptions.py rename to src/cloudevents/v1/kafka/exceptions.py index 6459f0a2..2adda993 100644 --- a/cloudevents/kafka/exceptions.py +++ b/src/cloudevents/v1/kafka/exceptions.py @@ -11,7 +11,7 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. -from cloudevents import exceptions as cloud_exceptions +from cloudevents.v1 import exceptions as cloud_exceptions class KeyMapperError(cloud_exceptions.GenericException): diff --git a/src/cloudevents/v1/py.typed b/src/cloudevents/v1/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/cloudevents/pydantic/__init__.py b/src/cloudevents/v1/pydantic/__init__.py similarity index 81% rename from cloudevents/pydantic/__init__.py rename to src/cloudevents/v1/pydantic/__init__.py index f8556ca1..b211f144 100644 --- a/cloudevents/pydantic/__init__.py +++ b/src/cloudevents/v1/pydantic/__init__.py @@ -14,24 +14,29 @@ from typing import TYPE_CHECKING -from cloudevents.exceptions import PydanticFeatureNotInstalled +from cloudevents.v1.exceptions import PydanticFeatureNotInstalled try: if TYPE_CHECKING: - from cloudevents.pydantic.v2 import CloudEvent, from_dict, from_http, from_json + from cloudevents.v1.pydantic.v2 import ( + CloudEvent, + from_dict, + from_http, + from_json, + ) else: from pydantic import VERSION as PYDANTIC_VERSION pydantic_major_version = PYDANTIC_VERSION.split(".")[0] if pydantic_major_version == "1": - from cloudevents.pydantic.v1 import ( + from cloudevents.v1.pydantic.v1 import ( CloudEvent, from_dict, from_http, from_json, ) else: - from cloudevents.pydantic.v2 import ( + from cloudevents.v1.pydantic.v2 import ( CloudEvent, from_dict, from_http, diff --git a/cloudevents/pydantic/fields_docs.py b/src/cloudevents/v1/pydantic/fields_docs.py similarity index 99% rename from cloudevents/pydantic/fields_docs.py rename to src/cloudevents/v1/pydantic/fields_docs.py index 00ed0bd3..947a2d98 100644 --- a/cloudevents/pydantic/fields_docs.py +++ b/src/cloudevents/v1/pydantic/fields_docs.py @@ -12,7 +12,7 @@ # License for the specific language governing permissions and limitations # under the License. -from cloudevents.sdk.event import attribute +from cloudevents.v1.sdk.event import attribute FIELD_DESCRIPTIONS = { "data": { diff --git a/cloudevents/pydantic/v2/__init__.py b/src/cloudevents/v1/pydantic/v1/__init__.py similarity index 83% rename from cloudevents/pydantic/v2/__init__.py rename to src/cloudevents/v1/pydantic/v1/__init__.py index 55d2a7fd..1b42629d 100644 --- a/cloudevents/pydantic/v2/__init__.py +++ b/src/cloudevents/v1/pydantic/v1/__init__.py @@ -12,7 +12,7 @@ # License for the specific language governing permissions and limitations # under the License. -from cloudevents.pydantic.v2.conversion import from_dict, from_http, from_json -from cloudevents.pydantic.v2.event import CloudEvent +from cloudevents.v1.pydantic.v1.conversion import from_dict, from_http, from_json +from cloudevents.v1.pydantic.v1.event import CloudEvent __all__ = ["CloudEvent", "from_json", "from_dict", "from_http"] diff --git a/cloudevents/pydantic/v1/conversion.py b/src/cloudevents/v1/pydantic/v1/conversion.py similarity index 88% rename from cloudevents/pydantic/v1/conversion.py rename to src/cloudevents/v1/pydantic/v1/conversion.py index 9f03372e..0fc6f2f2 100644 --- a/cloudevents/pydantic/v1/conversion.py +++ b/src/cloudevents/v1/pydantic/v1/conversion.py @@ -13,11 +13,11 @@ # under the License. import typing -from cloudevents.conversion import from_dict as _abstract_from_dict -from cloudevents.conversion import from_http as _abstract_from_http -from cloudevents.conversion import from_json as _abstract_from_json -from cloudevents.pydantic.v1.event import CloudEvent -from cloudevents.sdk import types +from cloudevents.v1.conversion import from_dict as _abstract_from_dict +from cloudevents.v1.conversion import from_http as _abstract_from_http +from cloudevents.v1.conversion import from_json as _abstract_from_json +from cloudevents.v1.pydantic.v1.event import CloudEvent +from cloudevents.v1.sdk import types def from_http( diff --git a/cloudevents/pydantic/v1/event.py b/src/cloudevents/v1/pydantic/v1/event.py similarity index 96% rename from cloudevents/pydantic/v1/event.py rename to src/cloudevents/v1/pydantic/v1/event.py index 98c61364..e8141af6 100644 --- a/cloudevents/pydantic/v1/event.py +++ b/src/cloudevents/v1/pydantic/v1/event.py @@ -15,8 +15,8 @@ import json import typing -from cloudevents.exceptions import PydanticFeatureNotInstalled -from cloudevents.pydantic.fields_docs import FIELD_DESCRIPTIONS +from cloudevents.v1.exceptions import PydanticFeatureNotInstalled +from cloudevents.v1.pydantic.fields_docs import FIELD_DESCRIPTIONS try: from pydantic import VERSION as PYDANTIC_VERSION @@ -32,9 +32,9 @@ "Install it using pip install cloudevents[pydantic]" ) -from cloudevents import abstract, conversion, http -from cloudevents.exceptions import IncompatibleArgumentsError -from cloudevents.sdk.event import attribute +from cloudevents.v1 import abstract, conversion, http +from cloudevents.v1.exceptions import IncompatibleArgumentsError +from cloudevents.v1.sdk.event import attribute def _ce_json_dumps( # type: ignore[no-untyped-def] @@ -71,7 +71,9 @@ def _ce_json_dumps( # type: ignore[no-untyped-def] def _ce_json_loads( # type: ignore[no-untyped-def] - data: typing.AnyStr, *args, **kwargs # noqa + data: typing.AnyStr, + *args, + **kwargs, # noqa ) -> typing.Dict[typing.Any, typing.Any]: """Performs Pydantic-specific deserialization of the event. diff --git a/cloudevents/pydantic/v1/__init__.py b/src/cloudevents/v1/pydantic/v2/__init__.py similarity index 83% rename from cloudevents/pydantic/v1/__init__.py rename to src/cloudevents/v1/pydantic/v2/__init__.py index e17151a4..f8e52a76 100644 --- a/cloudevents/pydantic/v1/__init__.py +++ b/src/cloudevents/v1/pydantic/v2/__init__.py @@ -12,7 +12,7 @@ # License for the specific language governing permissions and limitations # under the License. -from cloudevents.pydantic.v1.conversion import from_dict, from_http, from_json -from cloudevents.pydantic.v1.event import CloudEvent +from cloudevents.v1.pydantic.v2.conversion import from_dict, from_http, from_json +from cloudevents.v1.pydantic.v2.event import CloudEvent __all__ = ["CloudEvent", "from_json", "from_dict", "from_http"] diff --git a/cloudevents/pydantic/v2/conversion.py b/src/cloudevents/v1/pydantic/v2/conversion.py similarity index 88% rename from cloudevents/pydantic/v2/conversion.py rename to src/cloudevents/v1/pydantic/v2/conversion.py index 1745a572..afd17c79 100644 --- a/cloudevents/pydantic/v2/conversion.py +++ b/src/cloudevents/v1/pydantic/v2/conversion.py @@ -14,11 +14,11 @@ import typing -from cloudevents.conversion import from_dict as _abstract_from_dict -from cloudevents.conversion import from_http as _abstract_from_http -from cloudevents.conversion import from_json as _abstract_from_json -from cloudevents.pydantic.v2.event import CloudEvent -from cloudevents.sdk import types +from cloudevents.v1.conversion import from_dict as _abstract_from_dict +from cloudevents.v1.conversion import from_http as _abstract_from_http +from cloudevents.v1.conversion import from_json as _abstract_from_json +from cloudevents.v1.pydantic.v2.event import CloudEvent +from cloudevents.v1.sdk import types def from_http( diff --git a/cloudevents/pydantic/v2/event.py b/src/cloudevents/v1/pydantic/v2/event.py similarity index 96% rename from cloudevents/pydantic/v2/event.py rename to src/cloudevents/v1/pydantic/v2/event.py index 34a9b659..8bd1e3fe 100644 --- a/cloudevents/pydantic/v2/event.py +++ b/src/cloudevents/v1/pydantic/v2/event.py @@ -19,8 +19,8 @@ from pydantic.deprecated import parse as _deprecated_parse -from cloudevents.exceptions import PydanticFeatureNotInstalled -from cloudevents.pydantic.fields_docs import FIELD_DESCRIPTIONS +from cloudevents.v1.exceptions import PydanticFeatureNotInstalled +from cloudevents.v1.pydantic.fields_docs import FIELD_DESCRIPTIONS try: from pydantic import BaseModel, ConfigDict, Field, model_serializer @@ -30,9 +30,9 @@ "Install it using pip install cloudevents[pydantic]" ) -from cloudevents import abstract, conversion -from cloudevents.exceptions import IncompatibleArgumentsError -from cloudevents.sdk.event import attribute +from cloudevents.v1 import abstract, conversion +from cloudevents.v1.exceptions import IncompatibleArgumentsError +from cloudevents.v1.sdk.event import attribute class CloudEvent(abstract.CloudEvent, BaseModel): # type: ignore diff --git a/cloudevents/sdk/event/__init__.py b/src/cloudevents/v1/sdk/__init__.py similarity index 100% rename from cloudevents/sdk/event/__init__.py rename to src/cloudevents/v1/sdk/__init__.py diff --git a/cloudevents/sdk/converters/__init__.py b/src/cloudevents/v1/sdk/converters/__init__.py similarity index 82% rename from cloudevents/sdk/converters/__init__.py rename to src/cloudevents/v1/sdk/converters/__init__.py index cd8df680..6bf00cd9 100644 --- a/cloudevents/sdk/converters/__init__.py +++ b/src/cloudevents/v1/sdk/converters/__init__.py @@ -12,9 +12,9 @@ # License for the specific language governing permissions and limitations # under the License. -from cloudevents.sdk.converters import binary, structured -from cloudevents.sdk.converters.binary import is_binary -from cloudevents.sdk.converters.structured import is_structured +from cloudevents.v1.sdk.converters import binary, structured +from cloudevents.v1.sdk.converters.binary import is_binary +from cloudevents.v1.sdk.converters.structured import is_structured TypeBinary: str = binary.BinaryHTTPCloudEventConverter.TYPE TypeStructured: str = structured.JSONHTTPCloudEventConverter.TYPE diff --git a/cloudevents/sdk/converters/base.py b/src/cloudevents/v1/sdk/converters/base.py similarity index 97% rename from cloudevents/sdk/converters/base.py rename to src/cloudevents/v1/sdk/converters/base.py index 43edf5d2..182c3738 100644 --- a/cloudevents/sdk/converters/base.py +++ b/src/cloudevents/v1/sdk/converters/base.py @@ -14,7 +14,7 @@ import typing -from cloudevents.sdk.event import base +from cloudevents.v1.sdk.event import base class Converter(object): diff --git a/cloudevents/sdk/converters/binary.py b/src/cloudevents/v1/sdk/converters/binary.py similarity index 90% rename from cloudevents/sdk/converters/binary.py rename to src/cloudevents/v1/sdk/converters/binary.py index c5fcbf54..f1596ed4 100644 --- a/cloudevents/sdk/converters/binary.py +++ b/src/cloudevents/v1/sdk/converters/binary.py @@ -14,11 +14,11 @@ import typing -from cloudevents.sdk import exceptions, types -from cloudevents.sdk.converters import base -from cloudevents.sdk.converters.util import has_binary_headers -from cloudevents.sdk.event import base as event_base -from cloudevents.sdk.event import v1, v03 +from cloudevents.v1.sdk import exceptions, types +from cloudevents.v1.sdk.converters import base +from cloudevents.v1.sdk.converters.util import has_binary_headers +from cloudevents.v1.sdk.event import base as event_base +from cloudevents.v1.sdk.event import v03, v1 class BinaryHTTPCloudEventConverter(base.Converter): diff --git a/cloudevents/sdk/converters/structured.py b/src/cloudevents/v1/sdk/converters/structured.py similarity index 92% rename from cloudevents/sdk/converters/structured.py rename to src/cloudevents/v1/sdk/converters/structured.py index 24eda895..9b35784c 100644 --- a/cloudevents/sdk/converters/structured.py +++ b/src/cloudevents/v1/sdk/converters/structured.py @@ -14,10 +14,10 @@ import typing -from cloudevents.sdk import types -from cloudevents.sdk.converters import base -from cloudevents.sdk.converters.util import has_binary_headers -from cloudevents.sdk.event import base as event_base +from cloudevents.v1.sdk import types +from cloudevents.v1.sdk.converters import base +from cloudevents.v1.sdk.converters.util import has_binary_headers +from cloudevents.v1.sdk.event import base as event_base # TODO: Singleton? diff --git a/cloudevents/sdk/converters/util.py b/src/cloudevents/v1/sdk/converters/util.py similarity index 100% rename from cloudevents/sdk/converters/util.py rename to src/cloudevents/v1/sdk/converters/util.py diff --git a/cloudevents/tests/__init__.py b/src/cloudevents/v1/sdk/event/__init__.py similarity index 100% rename from cloudevents/tests/__init__.py rename to src/cloudevents/v1/sdk/event/__init__.py diff --git a/cloudevents/sdk/event/attribute.py b/src/cloudevents/v1/sdk/event/attribute.py similarity index 100% rename from cloudevents/sdk/event/attribute.py rename to src/cloudevents/v1/sdk/event/attribute.py diff --git a/cloudevents/sdk/event/base.py b/src/cloudevents/v1/sdk/event/base.py similarity index 98% rename from cloudevents/sdk/event/base.py rename to src/cloudevents/v1/sdk/event/base.py index 53e05d35..589e37c8 100644 --- a/cloudevents/sdk/event/base.py +++ b/src/cloudevents/v1/sdk/event/base.py @@ -17,8 +17,8 @@ import typing from typing import Set -import cloudevents.exceptions as cloud_exceptions -from cloudevents.sdk import types +import cloudevents.v1.exceptions as cloud_exceptions +from cloudevents.v1.sdk import types # TODO(slinkydeveloper) is this really needed? @@ -245,8 +245,7 @@ def UnmarshalJSON( decoded_value = value except Exception as e: raise cloud_exceptions.DataUnmarshallerError( - "Failed to unmarshall data with error: " - f"{type(e).__name__}('{e}')" + f"Failed to unmarshall data with error: {type(e).__name__}('{e}')" ) self.Set(name, decoded_value) diff --git a/cloudevents/sdk/event/opt.py b/src/cloudevents/v1/sdk/event/opt.py similarity index 100% rename from cloudevents/sdk/event/opt.py rename to src/cloudevents/v1/sdk/event/opt.py diff --git a/cloudevents/sdk/event/v03.py b/src/cloudevents/v1/sdk/event/v03.py similarity index 99% rename from cloudevents/sdk/event/v03.py rename to src/cloudevents/v1/sdk/event/v03.py index 6d69d2ab..4746adeb 100644 --- a/cloudevents/sdk/event/v03.py +++ b/src/cloudevents/v1/sdk/event/v03.py @@ -13,7 +13,7 @@ # under the License. import typing -from cloudevents.sdk.event import base, opt +from cloudevents.v1.sdk.event import base, opt class Event(base.BaseEvent): diff --git a/cloudevents/sdk/event/v1.py b/src/cloudevents/v1/sdk/event/v1.py similarity index 98% rename from cloudevents/sdk/event/v1.py rename to src/cloudevents/v1/sdk/event/v1.py index 0f2e1d50..b6db063b 100644 --- a/cloudevents/sdk/event/v1.py +++ b/src/cloudevents/v1/sdk/event/v1.py @@ -15,7 +15,7 @@ import typing -from cloudevents.sdk.event import base, opt +from cloudevents.v1.sdk.event import base, opt if typing.TYPE_CHECKING: from typing_extensions import Self diff --git a/cloudevents/sdk/exceptions.py b/src/cloudevents/v1/sdk/exceptions.py similarity index 91% rename from cloudevents/sdk/exceptions.py rename to src/cloudevents/v1/sdk/exceptions.py index 878bc704..eb9e250d 100644 --- a/cloudevents/sdk/exceptions.py +++ b/src/cloudevents/v1/sdk/exceptions.py @@ -36,6 +36,7 @@ def __init__(self, converter_type): class UnsupportedEventConverter(Exception): def __init__(self, content_type): super().__init__( - "Unable to identify valid event converter " - "for content-type: '{0}'".format(content_type) + "Unable to identify valid event converter for content-type: '{0}'".format( + content_type + ) ) diff --git a/cloudevents/sdk/marshaller.py b/src/cloudevents/v1/sdk/marshaller.py similarity index 96% rename from cloudevents/sdk/marshaller.py rename to src/cloudevents/v1/sdk/marshaller.py index dfd18965..a6555b40 100644 --- a/cloudevents/sdk/marshaller.py +++ b/src/cloudevents/v1/sdk/marshaller.py @@ -15,9 +15,9 @@ import json import typing -from cloudevents.sdk import exceptions, types -from cloudevents.sdk.converters import base, binary, structured -from cloudevents.sdk.event import base as event_base +from cloudevents.v1.sdk import exceptions, types +from cloudevents.v1.sdk.converters import base, binary, structured +from cloudevents.v1.sdk.event import base as event_base class HTTPMarshaller(object): diff --git a/cloudevents/sdk/types.py b/src/cloudevents/v1/sdk/types.py similarity index 100% rename from cloudevents/sdk/types.py rename to src/cloudevents/v1/sdk/types.py diff --git a/tests/test_cloudevents/__init__.py b/tests/test_cloudevents/__init__.py new file mode 100644 index 00000000..8043675e --- /dev/null +++ b/tests/test_cloudevents/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. diff --git a/tests/test_cloudevents/test_cloudevents_version.py b/tests/test_cloudevents/test_cloudevents_version.py new file mode 100644 index 00000000..d895c5f5 --- /dev/null +++ b/tests/test_cloudevents/test_cloudevents_version.py @@ -0,0 +1,19 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from cloudevents import __version__ + + +def test_cloudevents_version() -> None: + assert __version__ is not None diff --git a/tests/test_core/__init__.py b/tests/test_core/__init__.py new file mode 100644 index 00000000..8043675e --- /dev/null +++ b/tests/test_core/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. diff --git a/tests/test_core/test_bindings/__init__.py b/tests/test_core/test_bindings/__init__.py new file mode 100644 index 00000000..8043675e --- /dev/null +++ b/tests/test_core/test_bindings/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. diff --git a/tests/test_core/test_bindings/test_amqp.py b/tests/test_core/test_bindings/test_amqp.py new file mode 100644 index 00000000..d3a704b5 --- /dev/null +++ b/tests/test_core/test_bindings/test_amqp.py @@ -0,0 +1,876 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from datetime import datetime, timezone +from typing import Any + +import pytest + +from cloudevents.core.bindings.amqp import ( + AMQPMessage, + from_amqp, + from_binary, + from_structured, + to_binary, + to_structured, +) +from cloudevents.core.formats.json import JSONFormat +from cloudevents.core.v1.event import CloudEvent + + +@pytest.fixture +def minimal_attributes() -> dict[str, str]: + """Minimal valid CloudEvent attributes""" + return { + "type": "com.example.test", + "source": "/test", + "id": "test-id-123", + "specversion": "1.0", + } + + +def create_event( + extra_attrs: dict[str, Any] | None = None, + data: dict[str, Any] | str | bytes | None = None, +) -> CloudEvent: + """Helper to create CloudEvent with valid required attributes""" + attrs: dict[str, Any] = { + "type": "com.example.test", + "source": "/test", + "id": "test-id-123", + "specversion": "1.0", + } + if extra_attrs: + attrs.update(extra_attrs) + return CloudEvent(attributes=attrs, data=data) + + +def test_amqp_message_creation() -> None: + """Test basic AMQPMessage creation""" + message = AMQPMessage( + properties={"content-type": "application/json"}, + application_properties={"cloudEvents_type": "test"}, + application_data=b"test", + ) + assert message.properties == {"content-type": "application/json"} + assert message.application_properties == {"cloudEvents_type": "test"} + assert message.application_data == b"test" + + +def test_amqp_message_immutable() -> None: + """Test that AMQPMessage is immutable (frozen dataclass)""" + message = AMQPMessage( + properties={"test": "value"}, + application_properties={}, + application_data=b"data", + ) + + with pytest.raises(Exception): # FrozenInstanceError + message.properties = {"new": "dict"} + + with pytest.raises(Exception): # FrozenInstanceError + message.application_properties = {"new": "dict"} + + with pytest.raises(Exception): # FrozenInstanceError + message.application_data = b"new data" + + +def test_amqp_message_with_empty_properties() -> None: + """Test AMQPMessage with empty properties""" + message = AMQPMessage( + properties={}, application_properties={}, application_data=b"test" + ) + assert message.properties == {} + assert message.application_properties == {} + assert message.application_data == b"test" + + +def test_amqp_message_with_empty_application_data() -> None: + """Test AMQPMessage with empty application data""" + message = AMQPMessage( + properties={"test": "value"}, application_properties={}, application_data=b"" + ) + assert message.properties == {"test": "value"} + assert message.application_data == b"" + + +def test_to_binary_required_attributes() -> None: + """Test to_binary with only required attributes""" + event = create_event() + message = to_binary(event, JSONFormat()) + + assert "cloudEvents_type" in message.application_properties + assert message.application_properties["cloudEvents_type"] == "com.example.test" + assert message.application_properties["cloudEvents_source"] == "/test" + assert message.application_properties["cloudEvents_id"] == "test-id-123" + assert message.application_properties["cloudEvents_specversion"] == "1.0" + + +def test_to_binary_with_optional_attributes() -> None: + """Test to_binary with optional attributes""" + event = create_event( + extra_attrs={ + "subject": "test-subject", + "dataschema": "https://example.com/schema", + } + ) + message = to_binary(event, JSONFormat()) + + assert message.application_properties["cloudEvents_subject"] == "test-subject" + assert ( + message.application_properties["cloudEvents_dataschema"] + == "https://example.com/schema" + ) + + +def test_to_binary_with_extensions() -> None: + """Test to_binary with custom extension attributes""" + event = create_event(extra_attrs={"customext": "custom-value"}) + message = to_binary(event, JSONFormat()) + + assert message.application_properties["cloudEvents_customext"] == "custom-value" + + +def test_to_binary_datetime_as_timestamp() -> None: + """Test to_binary converts datetime to AMQP timestamp (milliseconds since epoch)""" + dt = datetime(2023, 1, 15, 10, 30, 45, tzinfo=timezone.utc) + event = create_event(extra_attrs={"time": dt}) + message = to_binary(event, JSONFormat()) + + # Should be serialized as AMQP timestamp (milliseconds since epoch) + expected_timestamp = int(dt.timestamp() * 1000) # 1673781045000 + assert message.application_properties["cloudEvents_time"] == expected_timestamp + assert isinstance(message.application_properties["cloudEvents_time"], int) + + +def test_to_binary_boolean_as_boolean() -> None: + """Test to_binary preserves boolean type (not converted to string)""" + event = create_event(extra_attrs={"boolext": True}) + message = to_binary(event, JSONFormat()) + + # Should be native boolean, not string "true" or "True" + assert message.application_properties["cloudEvents_boolext"] is True + assert isinstance(message.application_properties["cloudEvents_boolext"], bool) + + +def test_to_binary_integer_as_long() -> None: + """Test to_binary preserves integer type (not converted to string)""" + event = create_event(extra_attrs={"intext": 42}) + message = to_binary(event, JSONFormat()) + + # Should be native int/long, not string "42" + assert message.application_properties["cloudEvents_intext"] == 42 + assert isinstance(message.application_properties["cloudEvents_intext"], int) + + +def test_to_binary_datacontenttype_mapping() -> None: + """Test datacontenttype maps to AMQP content-type property""" + event = create_event( + extra_attrs={"datacontenttype": "application/json"}, data={"key": "value"} + ) + message = to_binary(event, JSONFormat()) + + # datacontenttype should go to properties, not application_properties + assert message.properties["content-type"] == "application/json" + assert "cloudEvents_datacontenttype" not in message.application_properties + + +def test_to_binary_with_json_data() -> None: + """Test to_binary with JSON dict data""" + event = create_event( + extra_attrs={"datacontenttype": "application/json"}, + data={"message": "Hello", "count": 42}, + ) + message = to_binary(event, JSONFormat()) + + # JSON serialization may vary in formatting, so check it can be parsed back + import json + + parsed = json.loads(message.application_data) + assert parsed == {"message": "Hello", "count": 42} + + +def test_to_binary_with_string_data() -> None: + """Test to_binary with string data""" + event = create_event(data="Hello World") + message = to_binary(event, JSONFormat()) + + # String data should be serialized + assert b"Hello World" in message.application_data + + +def test_to_binary_with_bytes_data() -> None: + """Test to_binary with bytes data""" + binary_data = b"\x00\x01\x02\x03" + event = create_event(data=binary_data) + message = to_binary(event, JSONFormat()) + + # Bytes should be preserved in application_data + assert len(message.application_data) > 0 + + +def test_to_binary_with_none_data() -> None: + """Test to_binary with None data""" + event = create_event(data=None) + message = to_binary(event, JSONFormat()) + + # None data should result in empty or null serialization + assert message.application_data is not None # Should be bytes + + +def test_from_binary_required_attributes() -> None: + """Test from_binary extracts required attributes""" + message = AMQPMessage( + properties={}, + application_properties={ + "cloudEvents_type": "com.example.test", + "cloudEvents_source": "/test", + "cloudEvents_id": "123", + "cloudEvents_specversion": "1.0", + }, + application_data=b"{}", + ) + event = from_binary(message, JSONFormat(), CloudEvent) + + assert event.get_type() == "com.example.test" + assert event.get_source() == "/test" + assert event.get_id() == "123" + assert event.get_specversion() == "1.0" + + +def test_from_binary_with_timestamp_property() -> None: + """Test from_binary parses AMQP timestamp (int milliseconds) to datetime""" + dt = datetime(2023, 1, 15, 10, 30, 45, tzinfo=timezone.utc) + timestamp_ms = int(dt.timestamp() * 1000) # 1673781045000 + + message = AMQPMessage( + properties={}, + application_properties={ + "cloudEvents_type": "test", + "cloudEvents_source": "/test", + "cloudEvents_id": "123", + "cloudEvents_specversion": "1.0", + "cloudEvents_time": timestamp_ms, # AMQP timestamp as int + }, + application_data=b"{}", + ) + event = from_binary(message, JSONFormat(), CloudEvent) + + assert event.get_time() == dt + assert isinstance(event.get_time(), datetime) + + +def test_from_binary_with_timestamp_string() -> None: + """Test from_binary also accepts ISO 8601 string (canonical form per spec)""" + dt = datetime(2023, 1, 15, 10, 30, 45, tzinfo=timezone.utc) + + message = AMQPMessage( + properties={}, + application_properties={ + "cloudEvents_type": "test", + "cloudEvents_source": "/test", + "cloudEvents_id": "123", + "cloudEvents_specversion": "1.0", + "cloudEvents_time": "2023-01-15T10:30:45Z", # ISO 8601 string (also valid) + }, + application_data=b"{}", + ) + event = from_binary(message, JSONFormat(), CloudEvent) + + assert event.get_time() == dt + assert isinstance(event.get_time(), datetime) + + +def test_from_binary_with_boolean_property() -> None: + """Test from_binary preserves boolean type""" + message = AMQPMessage( + properties={}, + application_properties={ + "cloudEvents_type": "test", + "cloudEvents_source": "/test", + "cloudEvents_id": "123", + "cloudEvents_specversion": "1.0", + "cloudEvents_boolext": True, + }, + application_data=b"{}", + ) + event = from_binary(message, JSONFormat(), CloudEvent) + + assert event.get_extension("boolext") is True + assert isinstance(event.get_extension("boolext"), bool) + + +def test_from_binary_with_long_property() -> None: + """Test from_binary preserves integer/long type""" + message = AMQPMessage( + properties={}, + application_properties={ + "cloudEvents_type": "test", + "cloudEvents_source": "/test", + "cloudEvents_id": "123", + "cloudEvents_specversion": "1.0", + "cloudEvents_intext": 42, + }, + application_data=b"{}", + ) + event = from_binary(message, JSONFormat(), CloudEvent) + + assert event.get_extension("intext") == 42 + assert isinstance(event.get_extension("intext"), int) + + +def test_from_binary_with_json_data() -> None: + """Test from_binary with JSON data""" + message = AMQPMessage( + properties={"content-type": "application/json"}, + application_properties={ + "cloudEvents_type": "test", + "cloudEvents_source": "/test", + "cloudEvents_id": "123", + "cloudEvents_specversion": "1.0", + }, + application_data=b'{"message": "Hello"}', + ) + event = from_binary(message, JSONFormat(), CloudEvent) + + assert event.get_data() == {"message": "Hello"} + assert event.get_datacontenttype() == "application/json" + + +def test_from_binary_with_text_data() -> None: + """Test from_binary with text data""" + message = AMQPMessage( + properties={"content-type": "text/plain"}, + application_properties={ + "cloudEvents_type": "test", + "cloudEvents_source": "/test", + "cloudEvents_id": "123", + "cloudEvents_specversion": "1.0", + }, + application_data=b"Hello World", + ) + event = from_binary(message, JSONFormat(), CloudEvent) + + # JSONFormat will decode as UTF-8 string for non-JSON content types + assert event.get_data() == "Hello World" + + +def test_from_binary_with_bytes_data() -> None: + """Test from_binary with binary data""" + binary_data = b"\x00\x01\x02\x03" + message = AMQPMessage( + properties={"content-type": "application/octet-stream"}, + application_properties={ + "cloudEvents_type": "test", + "cloudEvents_source": "/test", + "cloudEvents_id": "123", + "cloudEvents_specversion": "1.0", + }, + application_data=binary_data, + ) + event = from_binary(message, JSONFormat(), CloudEvent) + + # Binary data should be preserved + assert isinstance(event.get_data(), (bytes, str)) + + +def test_binary_round_trip() -> None: + """Test binary mode round-trip preserves event data""" + original = create_event( + extra_attrs={"subject": "test-subject", "datacontenttype": "application/json"}, + data={"message": "Hello", "count": 42}, + ) + + message = to_binary(original, JSONFormat()) + recovered = from_binary(message, JSONFormat(), CloudEvent) + + assert recovered.get_type() == original.get_type() + assert recovered.get_source() == original.get_source() + assert recovered.get_id() == original.get_id() + assert recovered.get_specversion() == original.get_specversion() + assert recovered.get_subject() == original.get_subject() + assert recovered.get_data() == original.get_data() + + +def test_binary_preserves_types() -> None: + """Test binary mode preserves native types (bool, int, datetime)""" + dt = datetime(2023, 1, 15, 10, 30, 45, tzinfo=timezone.utc) + original = create_event( + extra_attrs={"time": dt, "boolext": True, "intext": 42, "strext": "value"} + ) + + message = to_binary(original, JSONFormat()) + recovered = from_binary(message, JSONFormat(), CloudEvent) + + # Types should be preserved + assert recovered.get_time() == dt + assert isinstance(recovered.get_time(), datetime) + assert recovered.get_extension("boolext") is True + assert isinstance(recovered.get_extension("boolext"), bool) + assert recovered.get_extension("intext") == 42 + assert isinstance(recovered.get_extension("intext"), int) + assert recovered.get_extension("strext") == "value" + + +def test_structured_round_trip() -> None: + """Test structured mode round-trip preserves event data""" + original = create_event( + extra_attrs={"subject": "test-subject", "datacontenttype": "application/json"}, + data={"message": "Hello", "count": 42}, + ) + + message = to_structured(original, JSONFormat()) + recovered = from_structured(message, JSONFormat(), CloudEvent) + + assert recovered.get_type() == original.get_type() + assert recovered.get_source() == original.get_source() + assert recovered.get_id() == original.get_id() + assert recovered.get_specversion() == original.get_specversion() + assert recovered.get_subject() == original.get_subject() + assert recovered.get_data() == original.get_data() + + +def test_to_structured_basic_event() -> None: + """Test to_structured with basic event""" + event = create_event(data={"message": "Hello"}) + message = to_structured(event, JSONFormat()) + + # Should have content-type in properties + assert message.properties["content-type"] == "application/cloudevents+json" + + # application_data should contain the complete event + assert b"com.example.test" in message.application_data + assert b"message" in message.application_data + + +def test_to_structured_content_type_header() -> None: + """Test to_structured sets correct content-type""" + event = create_event() + message = to_structured(event, JSONFormat()) + + assert "content-type" in message.properties + assert message.properties["content-type"] == "application/cloudevents+json" + + +def test_to_structured_with_all_attributes() -> None: + """Test to_structured includes all attributes in serialized form""" + dt = datetime(2023, 1, 15, 10, 30, 45, tzinfo=timezone.utc) + event = create_event( + extra_attrs={ + "time": dt, + "subject": "test-subject", + "dataschema": "https://example.com/schema", + "customext": "custom-value", + }, + data={"message": "Hello"}, + ) + message = to_structured(event, JSONFormat()) + + # All attributes should be in the serialized data + assert b"test-subject" in message.application_data + assert b"customext" in message.application_data + + +def test_from_structured_basic_event() -> None: + """Test from_structured parses complete event""" + message = AMQPMessage( + properties={"content-type": "application/cloudevents+json"}, + application_properties={}, + application_data=b'{"type": "com.example.test", "source": "/test", ' + b'"id": "123", "specversion": "1.0", "data": {"message": "Hello"}}', + ) + event = from_structured(message, JSONFormat(), CloudEvent) + + assert event.get_type() == "com.example.test" + assert event.get_source() == "/test" + assert event.get_id() == "123" + assert event.get_data() == {"message": "Hello"} + + +def test_from_amqp_detects_binary_mode() -> None: + """Test from_amqp detects binary mode""" + message = AMQPMessage( + properties={"content-type": "application/json"}, + application_properties={ + "cloudEvents_type": "test", + "cloudEvents_source": "/test", + "cloudEvents_id": "123", + "cloudEvents_specversion": "1.0", + }, + application_data=b'{"message": "Hello"}', + ) + event = from_amqp(message, JSONFormat(), CloudEvent) + + assert event.get_type() == "test" + assert event.get_data() == {"message": "Hello"} + + +def test_from_amqp_detects_structured_mode() -> None: + """Test from_amqp detects structured mode""" + message = AMQPMessage( + properties={"content-type": "application/cloudevents+json"}, + application_properties={}, + application_data=b'{"type": "com.example.test", "source": "/test", ' + b'"id": "123", "specversion": "1.0"}', + ) + event = from_amqp(message, JSONFormat(), CloudEvent) + + assert event.get_type() == "com.example.test" + assert event.get_source() == "/test" + + +def test_from_amqp_case_insensitive_detection() -> None: + """Test from_amqp detection is case-insensitive""" + # Uppercase CLOUDEVENTS + message = AMQPMessage( + properties={"content-type": "application/CLOUDEVENTS+json"}, + application_properties={}, + application_data=b'{"type": "com.example.test", "source": "/test", ' + b'"id": "123", "specversion": "1.0"}', + ) + event = from_amqp(message, JSONFormat(), CloudEvent) + + assert event.get_type() == "com.example.test" + + +def test_from_amqp_defaults_to_binary_when_no_content_type() -> None: + """Test from_amqp defaults to binary mode when content-type is missing""" + message = AMQPMessage( + properties={}, # No content-type + application_properties={ + "cloudEvents_type": "test", + "cloudEvents_source": "/test", + "cloudEvents_id": "123", + "cloudEvents_specversion": "1.0", + }, + application_data=b"{}", + ) + event = from_amqp(message, JSONFormat(), CloudEvent) + + # Should successfully parse as binary mode + assert event.get_type() == "test" + + +def test_unicode_in_attributes() -> None: + """Test handling of unicode characters in attributes""" + event = create_event(extra_attrs={"subject": "测试-subject-🌍"}) + message = to_binary(event, JSONFormat()) + recovered = from_binary(message, JSONFormat(), CloudEvent) + + assert recovered.get_subject() == "测试-subject-🌍" + + +def test_unicode_in_data() -> None: + """Test handling of unicode characters in data""" + event = create_event(data={"message": "Hello 世界 🌍"}) + message = to_binary(event, JSONFormat()) + recovered = from_binary(message, JSONFormat(), CloudEvent) + + # Data should be preserved, whether as dict or string representation + data = recovered.get_data() + if isinstance(data, dict): + assert data == {"message": "Hello 世界 🌍"} + else: + assert "Hello 世界 🌍" in str(data) + + +def test_datetime_utc_handling() -> None: + """Test datetime with UTC timezone""" + dt_utc = datetime(2023, 1, 15, 10, 30, 45, tzinfo=timezone.utc) + event = create_event(extra_attrs={"time": dt_utc}) + message = to_binary(event, JSONFormat()) + recovered = from_binary(message, JSONFormat(), CloudEvent) + + assert recovered.get_time() == dt_utc + + +def test_datetime_non_utc_handling() -> None: + """Test datetime with non-UTC timezone""" + from datetime import timedelta + + # Create a custom timezone (UTC+5) + custom_tz = timezone(timedelta(hours=5)) + dt_custom = datetime(2023, 1, 15, 10, 30, 45, tzinfo=custom_tz) + + event = create_event(extra_attrs={"time": dt_custom}) + message = to_binary(event, JSONFormat()) + recovered = from_binary(message, JSONFormat(), CloudEvent) + + # Datetime should be preserved + assert recovered.get_time() == dt_custom + + +def test_empty_application_properties() -> None: + """Test message with no application properties (structured mode)""" + message = AMQPMessage( + properties={"content-type": "application/cloudevents+json"}, + application_properties={}, + application_data=b'{"type": "test", "source": "/test", "id": "123", ' + b'"specversion": "1.0"}', + ) + event = from_structured(message, JSONFormat(), CloudEvent) + + assert event.get_type() == "test" + + +def test_to_binary_with_multiple_extensions() -> None: + """Test to_binary with multiple custom extensions""" + event = create_event( + extra_attrs={ + "ext1": "value1", + "ext2": "value2", + "ext3": 123, + "ext4": True, + } + ) + message = to_binary(event, JSONFormat()) + + assert message.application_properties["cloudEvents_ext1"] == "value1" + assert message.application_properties["cloudEvents_ext2"] == "value2" + assert message.application_properties["cloudEvents_ext3"] == 123 + assert message.application_properties["cloudEvents_ext4"] is True + + +def test_from_binary_ignores_non_cloudevents_properties() -> None: + """Test from_binary only extracts cloudEvents_ prefixed properties""" + message = AMQPMessage( + properties={}, + application_properties={ + "cloudEvents_type": "test", + "cloudEvents_source": "/test", + "cloudEvents_id": "123", + "cloudEvents_specversion": "1.0", + "custom_property": "should-be-ignored", # No cloudEvents_ prefix + "another_prop": "also-ignored", + }, + application_data=b"{}", + ) + event = from_binary(message, JSONFormat(), CloudEvent) + + # Only cloudEvents_ prefixed properties should be extracted + assert event.get_type() == "test" + # Non-prefixed properties should not become extensions + # get_extension returns None for missing extensions + assert event.get_extension("custom_property") is None + assert event.get_extension("another_prop") is None + + +def test_from_binary_with_colon_prefix() -> None: + """Test from_binary accepts cloudEvents: prefix per AMQP spec""" + message = AMQPMessage( + properties={"content-type": "application/json"}, + application_properties={ + "cloudEvents:type": "com.example.test", + "cloudEvents:source": "/test", + "cloudEvents:id": "test-123", + "cloudEvents:specversion": "1.0", + }, + application_data=b'{"message": "Hello"}', + ) + event = from_binary(message, JSONFormat(), CloudEvent) + + assert event.get_type() == "com.example.test" + assert event.get_source() == "/test" + assert event.get_id() == "test-123" + assert event.get_specversion() == "1.0" + assert event.get_data() == {"message": "Hello"} + + +def test_from_binary_colon_prefix_with_extensions() -> None: + """Test from_binary with cloudEvents: prefix handles extensions""" + message = AMQPMessage( + properties={}, + application_properties={ + "cloudEvents:type": "test", + "cloudEvents:source": "/test", + "cloudEvents:id": "123", + "cloudEvents:specversion": "1.0", + "cloudEvents:customext": "custom-value", + "cloudEvents:boolext": True, + "cloudEvents:intext": 42, + }, + application_data=b"{}", + ) + event = from_binary(message, JSONFormat(), CloudEvent) + + assert event.get_extension("customext") == "custom-value" + assert event.get_extension("boolext") is True + assert event.get_extension("intext") == 42 + + +def test_from_binary_colon_prefix_with_datetime() -> None: + """Test from_binary with cloudEvents: prefix handles datetime""" + dt = datetime(2023, 1, 15, 10, 30, 45, tzinfo=timezone.utc) + timestamp_ms = int(dt.timestamp() * 1000) + + message = AMQPMessage( + properties={}, + application_properties={ + "cloudEvents:type": "test", + "cloudEvents:source": "/test", + "cloudEvents:id": "123", + "cloudEvents:specversion": "1.0", + "cloudEvents:time": timestamp_ms, # AMQP timestamp + }, + application_data=b"{}", + ) + event = from_binary(message, JSONFormat(), CloudEvent) + + assert event.get_time() == dt + + +def test_from_binary_colon_prefix_round_trip() -> None: + """Test round-trip with cloudEvents: prefix (manual construction)""" + # Create event with underscore prefix + original_event = create_event( + extra_attrs={"customext": "value", "datacontenttype": "application/json"}, + data={"message": "test"}, + ) + message_underscore = to_binary(original_event, JSONFormat()) + + # Manually construct message with colon prefix (simulate receiving from another system) + message_colon = AMQPMessage( + properties=message_underscore.properties, + application_properties={ + # Convert underscore to colon prefix + key.replace("cloudEvents_", "cloudEvents:"): value + for key, value in message_underscore.application_properties.items() + }, + application_data=message_underscore.application_data, + ) + + # Should parse correctly + recovered = from_binary(message_colon, JSONFormat(), CloudEvent) + + assert recovered.get_type() == original_event.get_type() + assert recovered.get_source() == original_event.get_source() + assert recovered.get_extension("customext") == "value" + assert recovered.get_data() == {"message": "test"} + + +def test_from_binary_mixed_prefixes_accepted() -> None: + """Test from_binary accepts mixed cloudEvents_ and cloudEvents: prefixes""" + message = AMQPMessage( + properties={}, + application_properties={ + "cloudEvents_type": "test", # Underscore + "cloudEvents:source": "/test", # Colon - mixed is OK + "cloudEvents_id": "123", + "cloudEvents_specversion": "1.0", + }, + application_data=b"{}", + ) + + event = from_binary(message, JSONFormat(), CloudEvent) + + # Should extract all attributes regardless of prefix + assert event.get_type() == "test" + assert event.get_source() == "/test" + assert event.get_id() == "123" + assert event.get_specversion() == "1.0" + + +def test_from_amqp_with_colon_prefix_binary_mode() -> None: + """Test from_amqp detects binary mode with cloudEvents: prefix""" + message = AMQPMessage( + properties={"content-type": "application/json"}, + application_properties={ + "cloudEvents:type": "test", + "cloudEvents:source": "/test", + "cloudEvents:id": "123", + "cloudEvents:specversion": "1.0", + }, + application_data=b'{"data": "value"}', + ) + + event = from_amqp(message, JSONFormat(), CloudEvent) + + assert event.get_type() == "test" + assert event.get_source() == "/test" + assert event.get_data() == {"data": "value"} + + +def test_from_amqp_mixed_prefixes_accepted() -> None: + """Test from_amqp accepts mixed prefixes""" + message = AMQPMessage( + properties={"content-type": "application/json"}, + application_properties={ + "cloudEvents_type": "test", + "cloudEvents:source": "/test", # Mixed is OK + "cloudEvents_id": "123", + "cloudEvents_specversion": "1.0", + }, + application_data=b"{}", + ) + + event = from_amqp(message, JSONFormat(), CloudEvent) + + assert event.get_type() == "test" + assert event.get_source() == "/test" + + +def test_from_binary_all_underscore_prefix_valid() -> None: + """Test from_binary accepts all cloudEvents_ prefix (baseline)""" + message = AMQPMessage( + properties={}, + application_properties={ + "cloudEvents_type": "test", + "cloudEvents_source": "/test", + "cloudEvents_id": "123", + "cloudEvents_specversion": "1.0", + }, + application_data=b"{}", + ) + + event = from_binary(message, JSONFormat(), CloudEvent) + assert event.get_type() == "test" + + +def test_from_binary_all_colon_prefix_valid() -> None: + """Test from_binary accepts all cloudEvents: prefix""" + message = AMQPMessage( + properties={}, + application_properties={ + "cloudEvents:type": "test", + "cloudEvents:source": "/test", + "cloudEvents:id": "123", + "cloudEvents:specversion": "1.0", + }, + application_data=b"{}", + ) + + event = from_binary(message, JSONFormat(), CloudEvent) + assert event.get_type() == "test" + + +def test_from_binary_colon_prefix_ignores_non_ce_properties() -> None: + """Test from_binary with colon prefix ignores non-CloudEvents properties""" + message = AMQPMessage( + properties={}, + application_properties={ + "cloudEvents:type": "test", + "cloudEvents:source": "/test", + "cloudEvents:id": "123", + "cloudEvents:specversion": "1.0", + "customProperty": "ignored", # No prefix + "anotherProp": 123, + }, + application_data=b"{}", + ) + + event = from_binary(message, JSONFormat(), CloudEvent) + + assert event.get_type() == "test" + assert event.get_extension("customProperty") is None + assert event.get_extension("anotherProp") is None diff --git a/tests/test_core/test_bindings/test_http.py b/tests/test_core/test_bindings/test_http.py new file mode 100644 index 00000000..cf46ba4d --- /dev/null +++ b/tests/test_core/test_bindings/test_http.py @@ -0,0 +1,1141 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from datetime import datetime, timezone +from typing import Any + +import pytest + +from cloudevents.core.bindings.http import ( + HTTPMessage, + from_binary, + from_binary_event, + from_http, + from_http_event, + from_structured, + from_structured_event, + to_binary, + to_binary_event, + to_structured, + to_structured_event, +) +from cloudevents.core.formats.json import JSONFormat +from cloudevents.core.v1.event import CloudEvent + + +@pytest.fixture +def minimal_attributes() -> dict[str, str]: + """Minimal valid CloudEvent attributes""" + return { + "type": "com.example.test", + "source": "/test", + "id": "test-id-123", + "specversion": "1.0", + } + + +def create_event( + extra_attrs: dict[str, Any] | None = None, + data: dict[str, Any] | str | bytes | None = None, +) -> CloudEvent: + """Helper to create CloudEvent with valid required attributes""" + attrs: dict[str, Any] = { + "type": "com.example.test", + "source": "/test", + "id": "test-id-123", + "specversion": "1.0", + } + if extra_attrs: + attrs.update(extra_attrs) + return CloudEvent(attributes=attrs, data=data) + + +def test_http_message_creation() -> None: + """Test basic HTTPMessage creation""" + message = HTTPMessage(headers={"content-type": "application/json"}, body=b"test") + assert message.headers == {"content-type": "application/json"} + assert message.body == b"test" + + +def test_http_message_immutable() -> None: + """Test that HTTPMessage is immutable (frozen dataclass)""" + message = HTTPMessage(headers={"test": "value"}, body=b"data") + + with pytest.raises(Exception): # FrozenInstanceError + message.headers = {"new": "dict"} + + with pytest.raises(Exception): # FrozenInstanceError + message.body = b"new data" + + +def test_http_message_with_empty_headers() -> None: + """Test HTTPMessage with empty headers""" + message = HTTPMessage(headers={}, body=b"test") + assert message.headers == {} + assert message.body == b"test" + + +def test_http_message_with_empty_body() -> None: + """Test HTTPMessage with empty body""" + message = HTTPMessage(headers={"test": "value"}, body=b"") + assert message.headers == {"test": "value"} + assert message.body == b"" + + +def test_http_message_equality() -> None: + """Test HTTPMessage equality comparison""" + msg1 = HTTPMessage(headers={"test": "value"}, body=b"data") + msg2 = HTTPMessage(headers={"test": "value"}, body=b"data") + msg3 = HTTPMessage(headers={"other": "value"}, body=b"data") + + assert msg1 == msg2 + assert msg1 != msg3 + + +def test_to_binary_returns_http_message() -> None: + """Test that to_binary returns an HTTPMessage instance""" + event = create_event() + message = to_binary(event, JSONFormat()) + assert isinstance(message, HTTPMessage) + + +def test_to_binary_required_attributes() -> None: + """Test to_binary with only required attributes""" + event = create_event() + message = to_binary(event, JSONFormat()) + + assert "ce-type" in message.headers + assert message.headers["ce-type"] == "com.example.test" + assert "ce-source" in message.headers + assert message.headers["ce-source"] == "/test" # Printable ASCII is not encoded + assert "ce-id" in message.headers + assert message.headers["ce-id"] == "test-id-123" + assert "ce-specversion" in message.headers + assert message.headers["ce-specversion"] == "1.0" + + +def test_to_binary_with_optional_attributes() -> None: + """Test to_binary with optional attributes""" + event = create_event( + {"subject": "test-subject", "dataschema": "https://example.com/schema"}, + data=None, + ) + message = to_binary(event, JSONFormat()) + + assert message.headers["ce-subject"] == "test-subject" + # Printable ASCII (including : and /) is not encoded per CE spec 3.1.3.2 + assert message.headers["ce-dataschema"] == "https://example.com/schema" + + +def test_to_binary_with_extensions() -> None: + """Test to_binary with extension attributes""" + event = create_event( + {"customext": "custom-value", "anotherext": "another-value"}, + data=None, + ) + message = to_binary(event, JSONFormat()) + + assert message.headers["ce-customext"] == "custom-value" + assert message.headers["ce-anotherext"] == "another-value" + + +def test_to_binary_with_json_data() -> None: + """Test to_binary with dict (JSON) data""" + event = create_event( + {"datacontenttype": "application/json"}, + data={"message": "Hello", "count": 42}, + ) + message = to_binary(event, JSONFormat()) + + assert message.body == b'{"message": "Hello", "count": 42}' + assert message.headers["content-type"] == "application/json" + + +def test_to_binary_with_string_data() -> None: + """Test to_binary with string data""" + event = create_event( + {"datacontenttype": "text/plain"}, + data="Hello World", + ) + message = to_binary(event, JSONFormat()) + + assert message.body == b"Hello World" + assert message.headers["content-type"] == "text/plain" + + +def test_to_binary_with_bytes_data() -> None: + """Test to_binary with bytes data""" + event = create_event( + {"datacontenttype": "application/octet-stream"}, + data=b"\x00\x01\x02\x03", + ) + message = to_binary(event, JSONFormat()) + + assert message.body == b"\x00\x01\x02\x03" + assert message.headers["content-type"] == "application/octet-stream" + + +def test_to_binary_with_none_data() -> None: + """Test to_binary with None data""" + event = create_event() + message = to_binary(event, JSONFormat()) + + assert message.body == b"" + + +def test_to_binary_datetime_encoding() -> None: + """Test to_binary with datetime (time attribute)""" + dt = datetime(2023, 1, 15, 10, 30, 45, tzinfo=timezone.utc) + event = create_event( + {"time": dt}, + data=None, + ) + message = to_binary(event, JSONFormat()) + + # Should encode with 'Z' suffix for UTC, colons not encoded per CE spec + assert "ce-time" in message.headers + assert "2023-01-15T10:30:45Z" == message.headers["ce-time"] + + +def test_to_binary_special_characters() -> None: + """Test to_binary with special characters in attributes""" + event = create_event( + {"subject": "Hello World!"}, + data=None, + ) + message = to_binary(event, JSONFormat()) + + # Only space is encoded; ! is printable ASCII and left as-is per CE spec + assert "ce-subject" in message.headers + assert "Hello%20World!" == message.headers["ce-subject"] + + +def test_to_binary_datacontenttype_mapping() -> None: + """Test that datacontenttype maps to Content-Type header""" + event = create_event( + {"datacontenttype": "application/xml"}, + data=None, + ) + message = to_binary(event, JSONFormat()) + + assert "content-type" in message.headers + assert message.headers["content-type"] == "application/xml" + + +def test_to_binary_no_ce_prefix_on_content_type() -> None: + """Test that Content-Type header does not have ce- prefix""" + event = create_event( + {"datacontenttype": "application/json"}, + data={"test": "data"}, + ) + message = to_binary(event, JSONFormat()) + + assert "content-type" in message.headers + assert "ce-datacontenttype" not in message.headers + + +def test_to_binary_header_encoding() -> None: + """Test percent encoding in headers""" + event = create_event( + {"subject": "test with spaces and special: chars"}, + data=None, + ) + message = to_binary(event, JSONFormat()) + + # Per CE spec 3.1.3.2: only space, double-quote, percent, and non-printable ASCII encoded + encoded_subject = message.headers["ce-subject"] + assert " " not in encoded_subject # Spaces should be encoded + assert "%20" in encoded_subject # Encoded space + assert ":" in encoded_subject # Colon is printable ASCII, not encoded + + +def test_from_binary_accepts_http_message() -> None: + """Test that from_binary accepts HTTPMessage parameter""" + message = HTTPMessage( + headers={ + "ce-type": "com.example.test", + "ce-source": "/test", + "ce-id": "test-id", + "ce-specversion": "1.0", + }, + body=b"", + ) + event = from_binary(message, JSONFormat(), CloudEvent) + assert event.get_type() == "com.example.test" + + +def test_from_binary_required_attributes() -> None: + """Test from_binary parsing required attributes""" + message = HTTPMessage( + headers={ + "ce-type": "com.example.test", + "ce-source": "/test", + "ce-id": "test-123", + "ce-specversion": "1.0", + }, + body=b"", + ) + event = from_binary(message, JSONFormat(), CloudEvent) + + assert event.get_type() == "com.example.test" + assert event.get_source() == "/test" + assert event.get_id() == "test-123" + assert event.get_specversion() == "1.0" + + +def test_from_binary_with_optional_attributes() -> None: + """Test from_binary with optional attributes""" + message = HTTPMessage( + headers={ + "ce-type": "com.example.test", + "ce-source": "/test", + "ce-id": "test-123", + "ce-specversion": "1.0", + "ce-subject": "test-subject", + "ce-dataschema": "https://example.com/schema", + }, + body=b"", + ) + event = from_binary(message, JSONFormat(), CloudEvent) + + assert event.get_subject() == "test-subject" + assert event.get_dataschema() == "https://example.com/schema" + + +def test_from_binary_with_extensions() -> None: + """Test from_binary with extension attributes""" + message = HTTPMessage( + headers={ + "ce-type": "com.example.test", + "ce-source": "/test", + "ce-id": "test-123", + "ce-specversion": "1.0", + "ce-customext": "custom-value", + }, + body=b"", + ) + event = from_binary(message, JSONFormat(), CloudEvent) + + attributes = event.get_attributes() + assert attributes["customext"] == "custom-value" + + +def test_from_binary_with_json_data() -> None: + """Test from_binary with JSON body""" + message = HTTPMessage( + headers={ + "ce-type": "com.example.test", + "ce-source": "/test", + "ce-id": "test-123", + "ce-specversion": "1.0", + "content-type": "application/json", + }, + body=b'{"message": "Hello", "count": 42}', + ) + event = from_binary(message, JSONFormat(), CloudEvent) + + data = event.get_data() + assert isinstance(data, dict) + assert data["message"] == "Hello" + assert data["count"] == 42 + + +def test_from_binary_with_text_data() -> None: + """Test from_binary with text body""" + message = HTTPMessage( + headers={ + "ce-type": "com.example.test", + "ce-source": "/test", + "ce-id": "test-123", + "ce-specversion": "1.0", + "content-type": "text/plain", + }, + body=b"Hello World", + ) + event = from_binary(message, JSONFormat(), CloudEvent) + + data = event.get_data() + assert data == "Hello World" + + +def test_from_binary_with_bytes_data() -> None: + """Test from_binary with binary body""" + # Use bytes that are NOT valid UTF-8 to test binary handling + message = HTTPMessage( + headers={ + "ce-type": "com.example.test", + "ce-source": "/test", + "ce-id": "test-123", + "ce-specversion": "1.0", + "content-type": "application/octet-stream", + }, + body=b"\xff\xfe\xfd\xfc", # Invalid UTF-8 bytes + ) + event = from_binary(message, JSONFormat(), CloudEvent) + + data = event.get_data() + # For non-UTF8 data, should remain as bytes + assert isinstance(data, bytes) + assert data == b"\xff\xfe\xfd\xfc" + + +def test_from_binary_datetime_parsing() -> None: + """Test from_binary parsing time attribute""" + message = HTTPMessage( + headers={ + "ce-type": "com.example.test", + "ce-source": "/test", + "ce-id": "test-123", + "ce-specversion": "1.0", + "ce-time": "2023-01-15T10%3A30%3A45Z", + }, + body=b"", + ) + event = from_binary(message, JSONFormat(), CloudEvent) + + time = event.get_time() + assert isinstance(time, datetime) + assert time.year == 2023 + assert time.month == 1 + assert time.day == 15 + + +def test_from_binary_header_decoding() -> None: + """Test percent decoding of headers""" + message = HTTPMessage( + headers={ + "ce-type": "com.example.test", + "ce-source": "/test", + "ce-id": "test-123", + "ce-specversion": "1.0", + "ce-subject": "Hello%20World%21", + }, + body=b"", + ) + event = from_binary(message, JSONFormat(), CloudEvent) + + # Should be percent-decoded + assert event.get_subject() == "Hello World!" + + +def test_from_binary_case_insensitive_headers() -> None: + """Test that header parsing is case-insensitive""" + message = HTTPMessage( + headers={ + "CE-Type": "com.example.test", + "Ce-Source": "/test", + "ce-ID": "test-123", + "CE-SPECVERSION": "1.0", + "Content-Type": "application/json", + }, + body=b'{"test": "data"}', + ) + event = from_binary(message, JSONFormat(), CloudEvent) + + assert event.get_type() == "com.example.test" + assert event.get_source() == "/test" + + +def test_from_binary_content_type_as_datacontenttype() -> None: + """Test that Content-Type header becomes datacontenttype attribute""" + message = HTTPMessage( + headers={ + "ce-type": "com.example.test", + "ce-source": "/test", + "ce-id": "test-123", + "ce-specversion": "1.0", + "content-type": "application/xml", + }, + body=b"data", + ) + event = from_binary(message, JSONFormat(), CloudEvent) + + assert event.get_datacontenttype() == "application/xml" + + +def test_from_binary_round_trip() -> None: + """Test that to_binary followed by from_binary preserves the event""" + original = create_event( + {"subject": "round-trip", "datacontenttype": "application/json"}, + data={"message": "Hello", "value": 123}, + ) + + # Convert to binary + message = to_binary(original, JSONFormat()) + + # Parse back + parsed = from_binary(message, JSONFormat(), CloudEvent) + + # Verify attributes + assert parsed.get_type() == original.get_type() + assert parsed.get_source() == original.get_source() + assert parsed.get_subject() == original.get_subject() + assert parsed.get_datacontenttype() == original.get_datacontenttype() + + # Verify data + assert parsed.get_data() == original.get_data() + + +def test_to_structured_returns_http_message() -> None: + """Test that to_structured returns an HTTPMessage instance""" + event = create_event() + message = to_structured(event, JSONFormat()) + assert isinstance(message, HTTPMessage) + + +def test_to_structured_basic_event() -> None: + """Test to_structured with basic event""" + event = create_event() + message = to_structured(event, JSONFormat()) + + # Should have JSON CloudEvents content type + assert message.headers["content-type"] == "application/cloudevents+json" + + # Body should contain serialized event + assert b'"type"' in message.body + assert b'"source"' in message.body + assert b"com.example.test" in message.body + + +def test_to_structured_content_type_header() -> None: + """Test that to_structured sets correct Content-Type header""" + event = create_event() + message = to_structured(event, JSONFormat()) + + assert "content-type" in message.headers + assert message.headers["content-type"] == "application/cloudevents+json" + + +def test_to_structured_with_all_attributes() -> None: + """Test to_structured with all attributes""" + event = create_event( + { + "subject": "test-subject", + "datacontenttype": "application/json", + "dataschema": "https://example.com/schema", + "customext": "custom-value", + }, + data={"message": "Hello"}, + ) + message = to_structured(event, JSONFormat()) + + # All attributes should be in the body + assert b'"type"' in message.body + assert b'"source"' in message.body + assert b'"subject"' in message.body + assert b'"datacontenttype"' in message.body + assert b'"dataschema"' in message.body + assert b'"customext"' in message.body + assert b'"data"' in message.body + + +def test_to_structured_with_binary_data() -> None: + """Test to_structured with binary data""" + event = create_event( + data=b"\x00\x01\x02\x03", + ) + message = to_structured(event, JSONFormat()) + + # Binary data should be base64 encoded in JSON + assert b'"data_base64"' in message.body + assert b'"data"' not in message.body # Should not have 'data' field + + +def test_from_structured_accepts_http_message() -> None: + """Test that from_structured accepts HTTPMessage parameter""" + message = HTTPMessage( + headers={"content-type": "application/cloudevents+json"}, + body=b'{"type": "com.example.test", "source": "/test", "id": "123", "specversion": "1.0"}', + ) + event = from_structured(message, JSONFormat(), CloudEvent) + assert event.get_type() == "com.example.test" + + +def test_from_structured_basic_event() -> None: + """Test from_structured with basic event""" + message = HTTPMessage( + headers={"content-type": "application/cloudevents+json"}, + body=b'{"type": "com.example.test", "source": "/test", "id": "123", "specversion": "1.0"}', + ) + event = from_structured(message, JSONFormat(), CloudEvent) + + assert event.get_type() == "com.example.test" + assert event.get_source() == "/test" + assert event.get_id() == "123" + assert event.get_specversion() == "1.0" + + +def test_from_structured_round_trip() -> None: + """Test that to_structured followed by from_structured preserves the event""" + original = create_event( + { + "subject": "round-trip", + "datacontenttype": "application/json", + "customext": "custom-value", + }, + data={"message": "Hello", "value": 123}, + ) + + # Convert to structured + message = to_structured(original, JSONFormat()) + + # Parse back + parsed = from_structured(message, JSONFormat(), CloudEvent) + + # Verify attributes + assert parsed.get_type() == original.get_type() + assert parsed.get_source() == original.get_source() + assert parsed.get_subject() == original.get_subject() + assert parsed.get_datacontenttype() == original.get_datacontenttype() + + # Verify data + assert parsed.get_data() == original.get_data() + + +def test_from_http_accepts_http_message() -> None: + """Test that from_http accepts HTTPMessage parameter""" + message = HTTPMessage( + headers={ + "ce-type": "com.example.test", + "ce-source": "/test", + "ce-id": "123", + "ce-specversion": "1.0", + }, + body=b"", + ) + event = from_http(message, JSONFormat(), CloudEvent) + assert event.get_type() == "com.example.test" + + +def test_from_http_detects_binary_mode() -> None: + """Test that from_http detects binary mode from ce- headers""" + message = HTTPMessage( + headers={ + "ce-type": "com.example.test", + "ce-source": "/test", + "ce-id": "123", + "ce-specversion": "1.0", + }, + body=b"test data", + ) + event = from_http(message, JSONFormat(), CloudEvent) + + assert event.get_type() == "com.example.test" + assert event.get_source() == "/test" + + +def test_from_http_detects_structured_mode() -> None: + """Test that from_http detects structured mode when no ce- headers""" + message = HTTPMessage( + headers={"content-type": "application/cloudevents+json"}, + body=b'{"type": "com.example.test", "source": "/test", "id": "123", "specversion": "1.0"}', + ) + event = from_http(message, JSONFormat(), CloudEvent) + + assert event.get_type() == "com.example.test" + assert event.get_source() == "/test" + + +def test_from_http_binary_mode_with_content_type() -> None: + """Test from_http with binary mode and Content-Type""" + message = HTTPMessage( + headers={ + "ce-type": "com.example.test", + "ce-source": "/test", + "ce-id": "123", + "ce-specversion": "1.0", + "content-type": "application/json", + }, + body=b'{"message": "Hello"}', + ) + event = from_http(message, JSONFormat(), CloudEvent) + + # Should detect binary mode due to ce- headers + data = event.get_data() + assert isinstance(data, dict) + assert data["message"] == "Hello" + + +def test_from_http_structured_mode_json() -> None: + """Test from_http with structured JSON event""" + message = HTTPMessage( + headers={"content-type": "application/cloudevents+json"}, + body=b'{"type": "com.example.test", "source": "/test", "id": "123", "specversion": "1.0", "data": {"msg": "Hi"}}', + ) + event = from_http(message, JSONFormat(), CloudEvent) + + assert event.get_type() == "com.example.test" + data = event.get_data() + assert isinstance(data, dict) + assert data["msg"] == "Hi" + + +def test_from_http_defaults_to_structured() -> None: + """Test that from_http defaults to structured mode when ambiguous""" + message = HTTPMessage( + headers={"content-type": "application/json"}, + body=b'{"type": "com.example.test", "source": "/test", "id": "123", "specversion": "1.0"}', + ) + event = from_http(message, JSONFormat(), CloudEvent) + + # Should parse as structured mode + assert event.get_type() == "com.example.test" + + +def test_from_http_case_insensitive_detection() -> None: + """Test that from_http detection is case-insensitive""" + message = HTTPMessage( + headers={ + "CE-Type": "com.example.test", + "CE-Source": "/test", + "CE-ID": "123", + "CE-SPECVERSION": "1.0", + }, + body=b"", + ) + event = from_http(message, JSONFormat(), CloudEvent) + + # Should detect binary mode despite mixed case + assert event.get_type() == "com.example.test" + + +def test_from_http_mixed_headers() -> None: + """Test from_http when both ce- headers and structured content are present""" + message = HTTPMessage( + headers={ + "ce-type": "com.example.binary", + "ce-source": "/binary", + "ce-id": "123", + "ce-specversion": "1.0", + "content-type": "application/cloudevents+json", + }, + body=b'{"type": "com.example.structured", "source": "/structured", "id": "456", "specversion": "1.0"}', + ) + event = from_http(message, JSONFormat(), CloudEvent) + + # Binary mode should take precedence (ce- headers present) + assert event.get_type() == "com.example.binary" + assert event.get_source() == "/binary" + + +def test_percent_encoding_special_chars() -> None: + """Test percent encoding of special characters""" + event = create_event( + {"subject": 'Hello World! "quotes" & special'}, + data=None, + ) + message = to_binary(event, JSONFormat()) + + # Per CE spec: space and double-quote are encoded, but & is printable ASCII + encoded = message.headers["ce-subject"] + assert " " not in encoded + assert '"' not in encoded + assert "&" in encoded # & is printable ASCII (U+0026), not encoded + + +def test_percent_encoding_spec_example() -> None: + """Test the example from CE HTTP binding spec section 3.1.3.2: + 'Euro € 😀' SHOULD be encoded as 'Euro%20%E2%82%AC%20%F0%9F%98%80' + """ + event = create_event( + {"subject": "Euro € 😀"}, + data=None, + ) + message = to_binary(event, JSONFormat()) + + assert message.headers["ce-subject"] == "Euro%20%E2%82%AC%20%F0%9F%98%80" + + # Round-trip: decode back to original + parsed = from_binary(message, JSONFormat(), CloudEvent) + assert parsed.get_subject() == "Euro € 😀" + + +def test_percent_encoding_unicode() -> None: + """Test percent encoding of unicode characters""" + event = create_event( + {"subject": "Hello 世界 🌍"}, + data=None, + ) + message = to_binary(event, JSONFormat()) + + # Unicode should be percent-encoded + encoded = message.headers["ce-subject"] + assert "世界" not in encoded + assert "🌍" not in encoded + assert "%" in encoded # Should have percent-encoded bytes + + +def test_percent_decoding_round_trip() -> None: + """Test that percent encoding/decoding is reversible""" + original_subject = 'Test: "quotes", spaces & unicode 世界' + event = create_event( + {"subject": original_subject}, + data=None, + ) + + # Encode + message = to_binary(event, JSONFormat()) + + # Decode + parsed = from_binary(message, JSONFormat(), CloudEvent) + + # Should match original + assert parsed.get_subject() == original_subject + + +def test_datetime_encoding_utc() -> None: + """Test datetime encoding for UTC timezone""" + dt_utc = datetime(2023, 6, 15, 14, 30, 45, tzinfo=timezone.utc) + event = create_event( + {"time": dt_utc}, + data=None, + ) + message = to_binary(event, JSONFormat()) + + # Should use 'Z' suffix for UTC + time_header = message.headers["ce-time"] + assert "Z" in time_header or "%5A" in time_header # Z or encoded Z + + +def test_datetime_encoding_non_utc() -> None: + """Test datetime encoding for non-UTC timezone""" + from datetime import timedelta + + # Create timezone +05:30 (IST) + dt_ist = datetime( + 2023, 6, 15, 14, 30, 45, tzinfo=timezone(timedelta(hours=5, minutes=30)) + ) + event = create_event( + {"time": dt_ist}, + data=None, + ) + message = to_binary(event, JSONFormat()) + + # Should preserve timezone offset + time_header = message.headers["ce-time"] + # Will be percent-encoded but should contain timezone info + assert "ce-time" in message.headers + + +def test_datetime_parsing_rfc3339() -> None: + """Test parsing various RFC 3339 datetime formats""" + test_cases = [ + "2023-01-15T10:30:45Z", + "2023-01-15T10%3A30%3A45Z", + "2023-01-15T10:30:45.123Z", + "2023-01-15T10:30:45%2B00:00", + ] + + for time_str in test_cases: + message = HTTPMessage( + headers={ + "ce-type": "com.example.test", + "ce-source": "/test", + "ce-id": "123", + "ce-specversion": "1.0", + "ce-time": time_str, + }, + body=b"", + ) + event = from_binary(message, JSONFormat(), CloudEvent) + + # Should successfully parse to datetime + time = event.get_time() + assert isinstance(time, datetime) + + +def test_http_binary_with_json_format() -> None: + """Test complete binary mode flow with JSON format""" + # Create event + event = create_event( + { + "type": "com.example.order.created", + "source": "/orders/service", + "subject": "order-123", + "datacontenttype": "application/json", + }, + data={"orderId": "123", "amount": 99.99, "status": "pending"}, + ) + + # Convert to HTTP binary mode + message = to_binary(event, JSONFormat()) + + # Verify headers + assert message.headers["ce-type"] == "com.example.order.created" + assert message.headers["content-type"] == "application/json" + + # Verify body + assert b'"orderId"' in message.body + assert b'"123"' in message.body + + # Parse back + parsed = from_binary(message, JSONFormat(), CloudEvent) + + # Verify round-trip + assert parsed.get_type() == event.get_type() + assert parsed.get_source() == event.get_source() + parsed_data = parsed.get_data() + assert isinstance(parsed_data, dict) + assert parsed_data["orderId"] == "123" + + +def test_http_structured_with_json_format() -> None: + """Test complete structured mode flow with JSON format""" + # Create event + event = create_event( + { + "type": "com.example.user.registered", + "source": "/users/service", + "datacontenttype": "application/json", + }, + data={"userId": "user-456", "email": "test@example.com"}, + ) + + # Convert to HTTP structured mode + message = to_structured(event, JSONFormat()) + + # Verify content type + assert message.headers["content-type"] == "application/cloudevents+json" + + # Verify body contains everything + assert b'"type"' in message.body + assert b'"source"' in message.body + assert b'"data"' in message.body + assert b'"userId"' in message.body + + # Parse back + parsed = from_structured(message, JSONFormat(), CloudEvent) + + # Verify round-trip + assert parsed.get_type() == event.get_type() + assert parsed.get_source() == event.get_source() + parsed_data = parsed.get_data() + assert isinstance(parsed_data, dict) + assert parsed_data["userId"] == "user-456" + + +def test_custom_event_factory() -> None: + """Test using custom event factory function""" + + def custom_factory( + attributes: dict[str, Any], data: dict[str, Any] | str | bytes | None + ) -> CloudEvent: + # Custom factory that adds a prefix to the type + attributes["type"] = f"custom.{attributes.get('type', 'unknown')}" + return CloudEvent(attributes, data) + + message = HTTPMessage( + headers={ + "ce-type": "test.event", + "ce-source": "/test", + "ce-id": "123", + "ce-specversion": "1.0", + }, + body=b"", + ) + + event = from_binary(message, JSONFormat(), custom_factory) + + # Should use custom factory + assert event.get_type() == "custom.test.event" + + +def test_real_world_scenario() -> None: + """Test a realistic end-to-end scenario""" + # Simulate a webhook notification + original_event = create_event( + { + "type": "com.github.push", + "source": "https://github.com/myorg/myrepo", + "subject": "refs/heads/main", + "datacontenttype": "application/json", + }, + data={ + "ref": "refs/heads/main", + "commits": [ + {"id": "abc123", "message": "Fix bug"}, + {"id": "def456", "message": "Add feature"}, + ], + }, + ) + + # Send as HTTP binary mode + http_message = to_binary(original_event, JSONFormat()) + + # Simulate network transmission (receiver side) + # Receiver auto-detects mode and parses + received_event = from_http(http_message, JSONFormat(), CloudEvent) + + # Verify data integrity + assert received_event.get_type() == "com.github.push" + assert received_event.get_source() == "https://github.com/myorg/myrepo" + assert received_event.get_subject() == "refs/heads/main" + + data = received_event.get_data() + assert isinstance(data, dict) + assert data["ref"] == "refs/heads/main" + assert len(data["commits"]) == 2 + assert data["commits"][0]["message"] == "Fix bug" + + +def test_to_binary_with_defaults() -> None: + """Test to_binary_event convenience wrapper using default JSONFormat""" + event = create_event( + extra_attrs={"datacontenttype": "application/json"}, + data={"message": "Hello"}, + ) + + message = to_binary_event(event) + + assert "ce-type" in message.headers + assert message.headers["ce-type"] == "com.example.test" + assert b'"message"' in message.body + assert b'"Hello"' in message.body + + +def test_to_structured_with_defaults() -> None: + """Test to_structured_event convenience wrapper using default JSONFormat""" + event = create_event(data={"message": "Hello"}) + + message = to_structured_event(event) + + assert "content-type" in message.headers + assert message.headers["content-type"] == "application/cloudevents+json" + assert b'"type"' in message.body + assert b'"com.example.test"' in message.body + assert b'"data"' in message.body + + +def test_from_binary_with_defaults() -> None: + """Test from_binary_event convenience wrapper using default JSONFormat and CloudEvent factory""" + message = HTTPMessage( + headers={ + "ce-type": "com.example.test", + "ce-source": "/test", + "ce-id": "123", + "ce-specversion": "1.0", + "content-type": "application/json", + }, + body=b'{"message": "Hello"}', + ) + + event = from_binary_event(message) + + assert isinstance(event, CloudEvent) + assert event.get_type() == "com.example.test" + assert event.get_source() == "/test" + assert event.get_id() == "123" + assert event.get_data() == {"message": "Hello"} + + +def test_from_structured_with_defaults() -> None: + """Test from_structured_event convenience wrapper using default JSONFormat and CloudEvent factory""" + message = HTTPMessage( + headers={"content-type": "application/cloudevents+json"}, + body=b'{"type": "com.example.test", "source": "/test", "id": "123", "specversion": "1.0", "data": {"message": "Hello"}}', + ) + + event = from_structured_event(message) + + assert isinstance(event, CloudEvent) + assert event.get_type() == "com.example.test" + assert event.get_source() == "/test" + assert event.get_id() == "123" + assert event.get_data() == {"message": "Hello"} + + +def test_from_http_with_defaults_binary() -> None: + """Test from_http_event convenience wrapper with auto-detection (binary mode)""" + message = HTTPMessage( + headers={ + "ce-type": "com.example.test", + "ce-source": "/test", + "ce-id": "123", + "ce-specversion": "1.0", + }, + body=b'{"message": "Hello"}', + ) + + event = from_http_event(message) + + assert isinstance(event, CloudEvent) + assert event.get_type() == "com.example.test" + assert event.get_source() == "/test" + + +def test_from_http_with_defaults_structured() -> None: + """Test from_http_event convenience wrapper with auto-detection (structured mode)""" + message = HTTPMessage( + headers={"content-type": "application/cloudevents+json"}, + body=b'{"type": "com.example.test", "source": "/test", "id": "123", "specversion": "1.0"}', + ) + + # Call wrapper function (should use defaults and detect structured mode) + event = from_http_event(message) + + assert isinstance(event, CloudEvent) + assert event.get_type() == "com.example.test" + assert event.get_source() == "/test" + + +def test_convenience_roundtrip_binary() -> None: + """Test complete roundtrip using convenience wrapper functions with binary mode""" + original_event = create_event( + extra_attrs={"datacontenttype": "application/json"}, + data={"message": "Roundtrip test"}, + ) + + # Convert to message using wrapper + message = to_binary_event(original_event) + + # Convert back using wrapper + recovered_event = from_binary_event(message) + + assert recovered_event.get_type() == original_event.get_type() + assert recovered_event.get_source() == original_event.get_source() + assert recovered_event.get_id() == original_event.get_id() + assert recovered_event.get_data() == original_event.get_data() + + +def test_convenience_roundtrip_structured() -> None: + """Test complete roundtrip using convenience wrapper functions with structured mode""" + original_event = create_event( + extra_attrs={"datacontenttype": "application/json"}, + data={"message": "Roundtrip test"}, + ) + + # Convert to message using wrapper + message = to_structured_event(original_event) + + # Convert back using wrapper + recovered_event = from_structured_event(message) + + assert recovered_event.get_type() == original_event.get_type() + assert recovered_event.get_source() == original_event.get_source() + assert recovered_event.get_id() == original_event.get_id() + assert recovered_event.get_data() == original_event.get_data() + + +def test_convenience_with_explicit_format_override() -> None: + """Test that wrapper functions can override format (still flexible)""" + event = create_event( + extra_attrs={"datacontenttype": "application/json"}, + data={"message": "Hello"}, + ) + + message = to_binary_event(event, JSONFormat()) + recovered = from_binary_event(message, JSONFormat()) + + assert recovered.get_type() == event.get_type() + assert recovered.get_data() == event.get_data() diff --git a/tests/test_core/test_bindings/test_kafka.py b/tests/test_core/test_bindings/test_kafka.py new file mode 100644 index 00000000..54df8221 --- /dev/null +++ b/tests/test_core/test_bindings/test_kafka.py @@ -0,0 +1,813 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from datetime import datetime, timezone +from typing import Any + +import pytest + +from cloudevents.core.base import BaseCloudEvent +from cloudevents.core.bindings.kafka import ( + KafkaMessage, + from_binary, + from_binary_event, + from_kafka, + from_kafka_event, + from_structured, + from_structured_event, + to_binary, + to_binary_event, + to_structured, + to_structured_event, +) +from cloudevents.core.formats.json import JSONFormat +from cloudevents.core.v1.event import CloudEvent + + +@pytest.fixture +def minimal_attributes() -> dict[str, str]: + """Minimal valid CloudEvent attributes""" + return { + "type": "com.example.test", + "source": "/test", + "id": "test-id-123", + "specversion": "1.0", + } + + +def create_event( + extra_attrs: dict[str, Any] | None = None, + data: dict[str, Any] | str | bytes | None = None, +) -> CloudEvent: + """Helper to create CloudEvent with valid required attributes""" + attrs: dict[str, Any] = { + "type": "com.example.test", + "source": "/test", + "id": "test-id-123", + "specversion": "1.0", + } + if extra_attrs: + attrs.update(extra_attrs) + return CloudEvent(attributes=attrs, data=data) + + +def test_kafka_message_creation() -> None: + """Test basic KafkaMessage creation""" + message = KafkaMessage( + headers={"content-type": b"application/json"}, + key=b"test-key", + value=b"test", + ) + assert message.headers == {"content-type": b"application/json"} + assert message.key == b"test-key" + assert message.value == b"test" + + +def test_kafka_message_immutable() -> None: + """Test that KafkaMessage is immutable (frozen dataclass)""" + message = KafkaMessage(headers={"test": b"value"}, key=None, value=b"data") + + with pytest.raises(Exception): # FrozenInstanceError + message.headers = {b"new": b"dict"} + + with pytest.raises(Exception): # FrozenInstanceError + message.value = b"new data" + + +def test_to_binary_required_attributes() -> None: + """Test to_binary with only required attributes""" + event = create_event() + message = to_binary(event, JSONFormat()) + + assert "ce_type" in message.headers + assert message.headers["ce_type"] == b"com.example.test" + assert "ce_source" in message.headers + assert message.headers["ce_source"] == b"/test" + assert "ce_id" in message.headers + assert message.headers["ce_id"] == b"test-id-123" + assert "ce_specversion" in message.headers + assert message.headers["ce_specversion"] == b"1.0" + + +def test_to_binary_with_optional_attributes() -> None: + """Test to_binary with optional attributes""" + event = create_event( + {"subject": "test-subject", "dataschema": "https://example.com/schema"}, + data=None, + ) + message = to_binary(event, JSONFormat()) + + assert message.headers["ce_subject"] == b"test-subject" + assert message.headers["ce_dataschema"] == b"https://example.com/schema" + + +def test_to_binary_with_extensions() -> None: + """Test to_binary with extension attributes""" + event = create_event( + {"customext": "custom-value", "anotherext": "another-value"}, + data=None, + ) + message = to_binary(event, JSONFormat()) + + assert message.headers["ce_customext"] == b"custom-value" + assert message.headers["ce_anotherext"] == b"another-value" + + +def test_to_binary_with_json_data() -> None: + """Test to_binary with dict (JSON) data and datacontenttype""" + event = create_event( + {"datacontenttype": "application/json"}, data={"message": "Hello", "count": 42} + ) + message = to_binary(event, JSONFormat()) + + # With application/json datacontenttype, data should be serialized as JSON + assert b'"message"' in message.value + assert b'"Hello"' in message.value + assert message.value != b"" + + +def test_to_binary_with_string_data() -> None: + """Test to_binary with string data""" + event = create_event(data="Hello World") + message = to_binary(event, JSONFormat()) + + assert message.value == b"Hello World" + + +def test_to_binary_with_bytes_data() -> None: + """Test to_binary with bytes data""" + event = create_event(data=b"\x00\x01\x02\x03") + message = to_binary(event, JSONFormat()) + + assert message.value == b"\x00\x01\x02\x03" + + +def test_to_binary_with_none_data() -> None: + """Test to_binary with None data""" + event = create_event(data=None) + message = to_binary(event, JSONFormat()) + + assert message.value == b"" + + +def test_to_binary_datetime_encoding() -> None: + """Test to_binary with datetime attribute""" + test_time = datetime(2023, 1, 15, 10, 30, 45, tzinfo=timezone.utc) + event = create_event({"time": test_time}) + message = to_binary(event, JSONFormat()) + + assert "ce_time" in message.headers + assert message.headers["ce_time"] == b"2023-01-15T10:30:45Z" + + +def test_to_binary_special_characters() -> None: + """Test to_binary with special characters in attributes""" + event = create_event({"subject": 'Hello World! "quotes" & special'}) + message = to_binary(event, JSONFormat()) + + assert "ce_subject" in message.headers + assert message.headers["ce_subject"] == b'Hello World! "quotes" & special' + + +def test_to_binary_datacontenttype_mapping() -> None: + """Test that datacontenttype maps to content-type header""" + event = create_event({"datacontenttype": "application/json"}, data={"test": "data"}) + message = to_binary(event, JSONFormat()) + + assert "content-type" in message.headers + assert message.headers["content-type"] == b"application/json" + assert "ce_datacontenttype" not in message.headers + + +def test_to_binary_partitionkey_in_key() -> None: + """Test that partitionkey becomes message key and is still included in headers""" + event = create_event({"partitionkey": "user-123"}) + message = to_binary(event, JSONFormat()) + + assert message.key == "user-123" + assert message.headers["ce_partitionkey"] == b"user-123" + + +def test_to_binary_custom_key_mapper() -> None: + """Test to_binary with custom key mapper""" + + def custom_mapper(event: BaseCloudEvent) -> str: + return f"custom-{event.get_type()}" + + event = create_event() + message = to_binary(event, JSONFormat(), key_mapper=custom_mapper) + + assert message.key == "custom-com.example.test" + + +def test_to_binary_no_partitionkey() -> None: + """Test to_binary without partitionkey returns None key""" + event = create_event() + message = to_binary(event, JSONFormat()) + + assert message.key is None + + +def test_from_binary_required_attributes() -> None: + """Test from_binary extracts required attributes""" + message = KafkaMessage( + headers={ + "ce_type": b"com.example.test", + "ce_source": b"/test", + "ce_id": b"test-123", + "ce_specversion": b"1.0", + }, + key=None, + value=b"", + ) + event = from_binary(message, JSONFormat(), CloudEvent) + + assert event.get_type() == "com.example.test" + assert event.get_source() == "/test" + assert event.get_id() == "test-123" + assert event.get_specversion() == "1.0" + + +def test_from_binary_with_optional_attributes() -> None: + """Test from_binary with optional attributes""" + message = KafkaMessage( + headers={ + "ce_type": b"com.example.test", + "ce_source": b"/test", + "ce_id": b"123", + "ce_specversion": b"1.0", + "ce_subject": b"test-subject", + "ce_dataschema": b"https://example.com/schema", + }, + key=None, + value=b"", + ) + event = from_binary(message, JSONFormat(), CloudEvent) + + assert event.get_subject() == "test-subject" + assert event.get_dataschema() == "https://example.com/schema" + + +def test_from_binary_with_extensions() -> None: + """Test from_binary with extension attributes""" + message = KafkaMessage( + headers={ + "ce_type": b"com.example.test", + "ce_source": b"/test", + "ce_id": b"123", + "ce_specversion": b"1.0", + "ce_customext": b"custom-value", + }, + key=None, + value=b"", + ) + event = from_binary(message, JSONFormat(), CloudEvent) + + assert event.get_extension("customext") == "custom-value" + + +def test_from_binary_with_json_data() -> None: + """Test from_binary with JSON data""" + message = KafkaMessage( + headers={ + "ce_type": b"com.example.test", + "ce_source": b"/test", + "ce_id": b"123", + "ce_specversion": b"1.0", + "content-type": b"application/json", + }, + key=None, + value=b'{"message": "Hello", "count": 42}', + ) + event = from_binary(message, JSONFormat(), CloudEvent) + + data = event.get_data() + assert isinstance(data, dict) + assert data["message"] == "Hello" + assert data["count"] == 42 + + +def test_from_binary_datetime_parsing() -> None: + """Test from_binary parses datetime correctly""" + message = KafkaMessage( + headers={ + "ce_type": b"com.example.test", + "ce_source": b"/test", + "ce_id": b"123", + "ce_specversion": b"1.0", + "ce_time": b"2023-01-15T10:30:45Z", + }, + key=None, + value=b"", + ) + event = from_binary(message, JSONFormat(), CloudEvent) + + time = event.get_time() + assert isinstance(time, datetime) + assert time.year == 2023 + assert time.month == 1 + assert time.day == 15 + + +def test_from_binary_case_insensitive_headers() -> None: + """Test from_binary handles case-insensitive headers""" + message = KafkaMessage( + headers={ + "CE_TYPE": b"com.example.test", + "CE_SOURCE": b"/test", + "ce_id": b"123", + "Ce_Specversion": b"1.0", + }, + key=None, + value=b"", + ) + event = from_binary(message, JSONFormat(), CloudEvent) + + assert event.get_type() == "com.example.test" + assert event.get_source() == "/test" + + +def test_from_binary_content_type_as_datacontenttype() -> None: + """Test that content-type header becomes datacontenttype attribute""" + message = KafkaMessage( + headers={ + "ce_type": b"com.example.test", + "ce_source": b"/test", + "ce_id": b"123", + "ce_specversion": b"1.0", + "content-type": b"application/json", + }, + key=None, + value=b'{"test": "data"}', + ) + event = from_binary(message, JSONFormat(), CloudEvent) + + assert event.get_datacontenttype() == "application/json" + + +def test_from_binary_key_to_partitionkey() -> None: + """Test that message key becomes partitionkey extension attribute""" + message = KafkaMessage( + headers={ + "ce_type": b"com.example.test", + "ce_source": b"/test", + "ce_id": b"123", + "ce_specversion": b"1.0", + }, + key=b"user-123", + value=b"", + ) + event = from_binary(message, JSONFormat(), CloudEvent) + + assert event.get_extension("partitionkey") == "user-123" + + +def test_from_binary_round_trip() -> None: + """Test round-trip conversion preserves all data""" + original = create_event( + { + "time": datetime(2023, 1, 15, 10, 30, 45, tzinfo=timezone.utc), + "subject": "test-subject", + "partitionkey": "user-456", + }, + data={"message": "Hello", "count": 42}, + ) + + message = to_binary(original, JSONFormat()) + recovered = from_binary(message, JSONFormat(), CloudEvent) + + assert recovered.get_type() == original.get_type() + assert recovered.get_source() == original.get_source() + assert recovered.get_id() == original.get_id() + assert recovered.get_subject() == original.get_subject() + assert recovered.get_extension("partitionkey") == "user-456" + + +def test_to_structured_basic_event() -> None: + """Test to_structured with basic event""" + event = create_event(data={"message": "Hello"}) + message = to_structured(event, JSONFormat()) + + assert "content-type" in message.headers + assert message.headers["content-type"] == b"application/cloudevents+json" + assert b"type" in message.value + assert b"source" in message.value + + +def test_to_structured_with_all_attributes() -> None: + """Test to_structured with all optional attributes""" + event = create_event( + { + "time": datetime(2023, 1, 15, 10, 30, 45, tzinfo=timezone.utc), + "subject": "test-subject", + "datacontenttype": "application/json", + "dataschema": "https://example.com/schema", + }, + data={"message": "Hello"}, + ) + message = to_structured(event, JSONFormat()) + + assert b"time" in message.value + assert b"subject" in message.value + assert b"datacontenttype" in message.value + + +def test_to_structured_partitionkey_in_key() -> None: + """Test that partitionkey becomes message key in structured mode""" + event = create_event({"partitionkey": "user-789"}) + message = to_structured(event, JSONFormat()) + + assert message.key == "user-789" + + +def test_to_structured_custom_key_mapper() -> None: + """Test to_structured with custom key mapper""" + + def custom_mapper(event: BaseCloudEvent) -> str: + return f"type-{event.get_type().split('.')[-1]}" + + event = create_event() + message = to_structured(event, JSONFormat(), key_mapper=custom_mapper) + + assert message.key == "type-test" + + +def test_to_structured_with_binary_data() -> None: + """Test to_structured with binary data (should be base64 encoded)""" + event = create_event(data=b"\x00\x01\x02\x03") + message = to_structured(event, JSONFormat()) + + # Binary data should be base64 encoded in structured mode + assert b"data_base64" in message.value + + +def test_from_structured_basic_event() -> None: + """Test from_structured with basic event""" + message = KafkaMessage( + headers={"content-type": b"application/cloudevents+json"}, + key=None, + value=b'{"type":"com.example.test","source":"/test","id":"123","specversion":"1.0","data":{"message":"Hello"}}', + ) + event = from_structured(message, JSONFormat(), CloudEvent) + + assert event.get_type() == "com.example.test" + assert event.get_source() == "/test" + assert event.get_data() == {"message": "Hello"} + + +def test_from_structured_key_to_partitionkey() -> None: + """Test that message key becomes partitionkey in structured mode""" + message = KafkaMessage( + headers={"content-type": b"application/cloudevents+json"}, + key=b"user-999", + value=b'{"type":"com.example.test","source":"/test","id":"123","specversion":"1.0"}', + ) + event = from_structured(message, JSONFormat(), CloudEvent) + + assert event.get_extension("partitionkey") == "user-999" + + +def test_from_structured_round_trip() -> None: + """Test structured mode round-trip""" + original = create_event( + { + "time": datetime(2023, 1, 15, 10, 30, 45, tzinfo=timezone.utc), + "subject": "test-subject", + "partitionkey": "key-123", + }, + data={"message": "Hello", "count": 42}, + ) + + message = to_structured(original, JSONFormat()) + recovered = from_structured(message, JSONFormat(), CloudEvent) + + assert recovered.get_type() == original.get_type() + assert recovered.get_source() == original.get_source() + assert recovered.get_extension("partitionkey") == "key-123" + + +def test_from_kafka_detects_binary_mode() -> None: + """Test from_kafka detects binary mode (ce_ headers present)""" + message = KafkaMessage( + headers={ + "ce_type": b"com.example.test", + "ce_source": b"/test", + "ce_id": b"123", + "ce_specversion": b"1.0", + }, + key=None, + value=b'{"message": "Hello"}', + ) + event = from_kafka(message, JSONFormat(), CloudEvent) + + assert event.get_type() == "com.example.test" + + +def test_from_kafka_detects_structured_mode() -> None: + """Test from_kafka detects structured mode (no ce_ headers)""" + message = KafkaMessage( + headers={"content-type": b"application/cloudevents+json"}, + key=None, + value=b'{"type":"com.example.test","source":"/test","id":"123","specversion":"1.0"}', + ) + event = from_kafka(message, JSONFormat(), CloudEvent) + + assert event.get_type() == "com.example.test" + + +def test_from_kafka_case_insensitive_detection() -> None: + """Test from_kafka detection is case-insensitive""" + message = KafkaMessage( + headers={ + "CE_TYPE": b"com.example.test", + "CE_SOURCE": b"/test", + "ce_id": b"123", + "ce_specversion": b"1.0", + }, + key=None, + value=b"", + ) + event = from_kafka(message, JSONFormat(), CloudEvent) + + assert event.get_type() == "com.example.test" + + +def test_from_kafka_binary_with_partitionkey() -> None: + """Test from_kafka binary mode with partition key""" + message = KafkaMessage( + headers={ + "ce_type": b"com.example.test", + "ce_source": b"/test", + "ce_id": b"123", + "ce_specversion": b"1.0", + }, + key=b"user-555", + value=b"", + ) + event = from_kafka(message, JSONFormat(), CloudEvent) + + assert event.get_extension("partitionkey") == "user-555" + + +def test_from_kafka_structured_with_partitionkey() -> None: + """Test from_kafka structured mode with partition key""" + message = KafkaMessage( + headers={"content-type": b"application/cloudevents+json"}, + key=b"user-666", + value=b'{"type":"com.example.test","source":"/test","id":"123","specversion":"1.0"}', + ) + event = from_kafka(message, JSONFormat(), CloudEvent) + + assert event.get_extension("partitionkey") == "user-666" + + +def test_empty_headers() -> None: + """Test handling of empty headers in structured mode""" + message = KafkaMessage( + headers={}, + key=None, + value=b'{"type":"com.example.test","source":"/test","id":"123","specversion":"1.0"}', + ) + # Should default to structured mode + event = from_kafka(message, JSONFormat(), CloudEvent) + assert event.get_type() == "com.example.test" + + +def test_unicode_in_attributes() -> None: + """Test handling of unicode characters in attributes""" + event = create_event({"subject": "Hello 世界 🌍"}) + message = to_binary(event, JSONFormat()) + recovered = from_binary(message, JSONFormat(), CloudEvent) + + assert recovered.get_subject() == "Hello 世界 🌍" + + +def test_unicode_in_data() -> None: + """Test handling of unicode characters in data""" + event = create_event( + {"datacontenttype": "application/json"}, data={"message": "Hello 世界 🌍"} + ) + message = to_binary(event, JSONFormat()) + recovered = from_binary(message, JSONFormat(), CloudEvent) + + assert isinstance(recovered.get_data(), dict) + assert recovered.get_data()["message"] == "Hello 世界 🌍" + + +def test_string_key_vs_bytes_key() -> None: + """Test that both string and bytes keys work""" + # String key + event1 = create_event({"partitionkey": "string-key"}) + msg1 = to_binary(event1, JSONFormat()) + assert msg1.key == "string-key" + + # Bytes key through custom mapper + def bytes_mapper(event: BaseCloudEvent) -> bytes: + return b"bytes-key" + + event2 = create_event() + msg2 = to_binary(event2, JSONFormat(), key_mapper=bytes_mapper) + assert msg2.key == b"bytes-key" + + +def test_to_binary_with_defaults() -> None: + """Test to_binary_event convenience wrapper using default JSONFormat""" + event = create_event( + extra_attrs={"datacontenttype": "application/json"}, + data={"message": "Hello"}, + ) + + message = to_binary_event(event) + + assert "ce_type" in message.headers + assert message.headers["ce_type"] == b"com.example.test" + assert b'"message"' in message.value + assert b'"Hello"' in message.value + + +def test_to_structured_with_defaults() -> None: + """Test to_structured_event convenience wrapper using default JSONFormat""" + event = create_event(data={"message": "Hello"}) + + message = to_structured_event(event) + + assert "content-type" in message.headers + assert message.headers["content-type"] == b"application/cloudevents+json" + assert b'"type"' in message.value + assert b'"com.example.test"' in message.value + assert b'"data"' in message.value + + +def test_from_binary_with_defaults() -> None: + """Test from_binary_event convenience wrapper using default JSONFormat and CloudEvent factory""" + message = KafkaMessage( + headers={ + "ce_type": b"com.example.test", + "ce_source": b"/test", + "ce_id": b"123", + "ce_specversion": b"1.0", + "content-type": b"application/json", + }, + key=None, + value=b'{"message": "Hello"}', + ) + + # Call wrapper function (should use defaults) + event = from_binary_event(message) + + assert isinstance(event, CloudEvent) + assert event.get_type() == "com.example.test" + assert event.get_source() == "/test" + assert event.get_id() == "123" + assert event.get_data() == {"message": "Hello"} + + +def test_from_structured_with_defaults() -> None: + """Test from_structured_event convenience wrapper using default JSONFormat and CloudEvent factory""" + message = KafkaMessage( + headers={"content-type": b"application/cloudevents+json"}, + key=None, + value=b'{"type": "com.example.test", "source": "/test", "id": "123", "specversion": "1.0", "data": {"message": "Hello"}}', + ) + + # Call wrapper function (should use defaults) + event = from_structured_event(message) + + assert isinstance(event, CloudEvent) + assert event.get_type() == "com.example.test" + assert event.get_source() == "/test" + assert event.get_id() == "123" + assert event.get_data() == {"message": "Hello"} + + +def test_from_kafka_with_defaults_binary() -> None: + """Test from_kafka_event convenience wrapper with auto-detection (binary mode)""" + message = KafkaMessage( + headers={ + "ce_type": b"com.example.test", + "ce_source": b"/test", + "ce_id": b"123", + "ce_specversion": b"1.0", + }, + key=None, + value=b'{"message": "Hello"}', + ) + + # Call wrapper function (should use defaults and detect binary mode) + event = from_kafka_event(message) + + assert isinstance(event, CloudEvent) + assert event.get_type() == "com.example.test" + assert event.get_source() == "/test" + + +def test_from_kafka_with_defaults_structured() -> None: + """Test from_kafka_event convenience wrapper with auto-detection (structured mode)""" + message = KafkaMessage( + headers={"content-type": b"application/cloudevents+json"}, + key=None, + value=b'{"type": "com.example.test", "source": "/test", "id": "123", "specversion": "1.0"}', + ) + + # Call wrapper function (should use defaults and detect structured mode) + event = from_kafka_event(message) + + assert isinstance(event, CloudEvent) + assert event.get_type() == "com.example.test" + assert event.get_source() == "/test" + + +def test_convenience_roundtrip_binary() -> None: + """Test complete roundtrip using convenience wrapper functions with binary mode""" + original_event = create_event( + extra_attrs={"datacontenttype": "application/json"}, + data={"message": "Roundtrip test"}, + ) + + # Convert to message using wrapper + message = to_binary_event(original_event) + + # Convert back using wrapper + recovered_event = from_binary_event(message) + + assert recovered_event.get_type() == original_event.get_type() + assert recovered_event.get_source() == original_event.get_source() + assert recovered_event.get_id() == original_event.get_id() + assert recovered_event.get_data() == original_event.get_data() + + +def test_convenience_roundtrip_structured() -> None: + """Test complete roundtrip using convenience wrapper functions with structured mode""" + original_event = create_event( + extra_attrs={"datacontenttype": "application/json"}, + data={"message": "Roundtrip test"}, + ) + + # Convert to message using wrapper + message = to_structured_event(original_event) + + # Convert back using wrapper + recovered_event = from_structured_event(message) + + assert recovered_event.get_type() == original_event.get_type() + assert recovered_event.get_source() == original_event.get_source() + assert recovered_event.get_id() == original_event.get_id() + assert recovered_event.get_data() == original_event.get_data() + + +def test_convenience_with_explicit_format_override() -> None: + """Test that wrapper functions can override format (still flexible)""" + event = create_event( + extra_attrs={"datacontenttype": "application/json"}, + data={"message": "Hello"}, + ) + + # Explicitly pass JSONFormat to wrapper function + message = to_binary_event(event, JSONFormat()) + recovered = from_binary_event(message, JSONFormat()) + + assert recovered.get_type() == event.get_type() + assert recovered.get_data() == event.get_data() + + +def test_from_structured_with_key_auto_detect_v1() -> None: + """Test that auto-detection works when message has key (v1.0)""" + message = KafkaMessage( + headers={"content-type": b"application/cloudevents+json"}, + key=b"partition-key-123", + value=b'{"specversion":"1.0","type":"com.example.test","source":"/test","id":"123"}', + ) + + # Auto-detect version (factory=None) + event = from_structured(message, JSONFormat()) + + assert event.get_type() == "com.example.test" + assert event.get_extension("partitionkey") == "partition-key-123" + assert event.get_attributes()["specversion"] == "1.0" + + +def test_from_structured_with_key_auto_detect_v03() -> None: + """Test that auto-detection works when message has key (v0.3)""" + message = KafkaMessage( + headers={"content-type": b"application/cloudevents+json"}, + key=b"partition-key-456", + value=b'{"specversion":"0.3","type":"com.example.test","source":"/test","id":"456"}', + ) + + # Auto-detect version (factory=None) + event = from_structured(message, JSONFormat()) + + assert event.get_type() == "com.example.test" + assert event.get_extension("partitionkey") == "partition-key-456" + assert event.get_attributes()["specversion"] == "0.3" diff --git a/tests/test_core/test_format/__init__.py b/tests/test_core/test_format/__init__.py new file mode 100644 index 00000000..8043675e --- /dev/null +++ b/tests/test_core/test_format/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. diff --git a/tests/test_core/test_format/test_json.py b/tests/test_core/test_format/test_json.py new file mode 100644 index 00000000..12f75435 --- /dev/null +++ b/tests/test_core/test_format/test_json.py @@ -0,0 +1,325 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +from datetime import datetime, timezone + +from cloudevents.core.formats.json import JSONFormat +from cloudevents.core.v1.event import CloudEvent + + +def test_write_cloud_event_to_json_with_attributes_only() -> None: + attributes = { + "id": "123", + "source": "source", + "type": "type", + "specversion": "1.0", + "time": datetime(2023, 10, 25, 17, 9, 19, 736166, tzinfo=timezone.utc), + "datacontenttype": "application/json", + "dataschema": "http://example.com/schema", + "subject": "test_subject", + } + event = CloudEvent(attributes=attributes, data=None) + formatter = JSONFormat() + result = formatter.write(event) + + assert ( + result + == '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "time": "2023-10-25T17:09:19.736166Z", "datacontenttype": "application/json", "dataschema": "http://example.com/schema", "subject": "test_subject"}'.encode( + "utf-8" + ) + ) + + +def test_write_cloud_event_to_json_with_data_as_json() -> None: + attributes = { + "id": "123", + "source": "source", + "type": "type", + "specversion": "1.0", + "time": datetime(2023, 10, 25, 17, 9, 19, 736166, tzinfo=timezone.utc), + "datacontenttype": "application/json", + "dataschema": "http://example.com/schema", + "subject": "test_subject", + } + event = CloudEvent(attributes=attributes, data={"key": "value"}) + formatter = JSONFormat() + result = formatter.write(event) + + assert ( + result + == '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "time": "2023-10-25T17:09:19.736166Z", "datacontenttype": "application/json", "dataschema": "http://example.com/schema", "subject": "test_subject", "data": {"key": "value"}}'.encode( + "utf-8" + ) + ) + + +def test_write_cloud_event_to_json_with_data_as_bytes() -> None: + attributes = { + "id": "123", + "source": "source", + "type": "type", + "specversion": "1.0", + "time": datetime(2023, 10, 25, 17, 9, 19, 736166, tzinfo=timezone.utc), + "datacontenttype": "application/json", + "dataschema": "http://example.com/schema", + "subject": "test_subject", + } + event = CloudEvent(attributes=attributes, data=b"test") + formatter = JSONFormat() + result = formatter.write(event) + + assert ( + result + == '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "time": "2023-10-25T17:09:19.736166Z", "datacontenttype": "application/json", "dataschema": "http://example.com/schema", "subject": "test_subject", "data_base64": "dGVzdA=="}'.encode( + "utf-8" + ) + ) + + +def test_write_cloud_event_to_json_with_data_as_str_and_content_type_not_json() -> None: + attributes = { + "id": "123", + "source": "source", + "type": "type", + "specversion": "1.0", + "time": datetime(2023, 10, 25, 17, 9, 19, 736166, tzinfo=timezone.utc), + "datacontenttype": "text/plain", + "dataschema": "http://example.com/schema", + "subject": "test_subject", + } + event = CloudEvent(attributes=attributes, data="test") + formatter = JSONFormat() + result = formatter.write(event) + + assert ( + result + == '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "time": "2023-10-25T17:09:19.736166Z", "datacontenttype": "text/plain", "dataschema": "http://example.com/schema", "subject": "test_subject", "data": "test"}'.encode( + "utf-8" + ) + ) + + +def test_write_cloud_event_to_json_with_no_content_type_set_and_data_as_str() -> None: + attributes = { + "id": "123", + "source": "source", + "type": "type", + "specversion": "1.0", + "time": datetime(2023, 10, 25, 17, 9, 19, 736166, tzinfo=timezone.utc), + "dataschema": "http://example.com/schema", + "subject": "test_subject", + } + event = CloudEvent(attributes=attributes, data="I'm just a string") + formatter = JSONFormat() + result = formatter.write(event) + + assert ( + result + == '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "time": "2023-10-25T17:09:19.736166Z", "dataschema": "http://example.com/schema", "subject": "test_subject", "data": "I\'m just a string"}'.encode( + "utf-8" + ) + ) + + +def test_write_cloud_event_to_json_with_no_content_type_set_and_data_as_json() -> None: + attributes = { + "id": "123", + "source": "source", + "type": "type", + "specversion": "1.0", + "time": datetime(2023, 10, 25, 17, 9, 19, 736166, tzinfo=timezone.utc), + "dataschema": "http://example.com/schema", + "subject": "test_subject", + } + event = CloudEvent(attributes=attributes, data={"key": "value"}) + formatter = JSONFormat() + result = formatter.write(event) + + assert ( + result + == '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "time": "2023-10-25T17:09:19.736166Z", "dataschema": "http://example.com/schema", "subject": "test_subject", "data": {"key": "value"}}'.encode( + "utf-8" + ) + ) + + +def test_read_cloud_event_from_json_with_attributes_only() -> None: + data = '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "time": "2023-10-25T17:09:19.736166Z", "datacontenttype": "application/json", "dataschema": "http://example.com/schema", "subject": "test_subject"}'.encode( + "utf-8" + ) + formatter = JSONFormat() + result = formatter.read(CloudEvent, data) + + assert result.get_id() == "123" + assert result.get_source() == "source" + assert result.get_type() == "type" + assert result.get_specversion() == "1.0" + assert result.get_time() == datetime( + 2023, 10, 25, 17, 9, 19, 736166, tzinfo=timezone.utc + ) + assert result.get_datacontenttype() == "application/json" + assert result.get_dataschema() == "http://example.com/schema" + assert result.get_subject() == "test_subject" + assert result.get_data() is None + + +def test_read_cloud_event_from_json_with_bytes_as_data() -> None: + data = '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "time": "2023-10-25T17:09:19.736166Z", "datacontenttype": "application/json", "dataschema": "http://example.com/schema", "subject": "test_subject", "data_base64": "dGVzdA=="}'.encode( + "utf-8" + ) + formatter = JSONFormat() + result = formatter.read(CloudEvent, data) + + assert result.get_id() == "123" + assert result.get_source() == "source" + assert result.get_type() == "type" + assert result.get_specversion() == "1.0" + assert result.get_time() == datetime( + 2023, 10, 25, 17, 9, 19, 736166, tzinfo=timezone.utc + ) + assert result.get_datacontenttype() == "application/json" + assert result.get_dataschema() == "http://example.com/schema" + assert result.get_subject() == "test_subject" + assert result.get_data() == b"test" + + +def test_read_cloud_event_from_json_with_json_as_data() -> None: + data = '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "time": "2023-10-25T17:09:19.736166Z", "datacontenttype": "application/json", "dataschema": "http://example.com/schema", "subject": "test_subject", "data": {"key": "value"}}'.encode( + "utf-8" + ) + formatter = JSONFormat() + result = formatter.read(CloudEvent, data) + + assert result.get_id() == "123" + assert result.get_source() == "source" + assert result.get_type() == "type" + assert result.get_specversion() == "1.0" + assert result.get_time() == datetime( + 2023, 10, 25, 17, 9, 19, 736166, tzinfo=timezone.utc + ) + assert result.get_datacontenttype() == "application/json" + assert result.get_dataschema() == "http://example.com/schema" + assert result.get_subject() == "test_subject" + assert result.get_data() == {"key": "value"} + + +def test_write_cloud_event_with_extension_attributes() -> None: + attributes = { + "id": "123", + "source": "source", + "type": "type", + "specversion": "1.0", + "customext1": "value1", + "customext2": 123, + } + event = CloudEvent(attributes=attributes, data=None) + formatter = JSONFormat() + result = formatter.write(event) + + assert b'"customext1": "value1"' in result + assert b'"customext2": 123' in result + + +def test_read_cloud_event_with_extension_attributes() -> None: + data = '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "customext1": "value1", "customext2": 123}'.encode( + "utf-8" + ) + formatter = JSONFormat() + result = formatter.read(CloudEvent, data) + + assert result.get_extension("customext1") == "value1" + assert result.get_extension("customext2") == 123 + + +def test_write_cloud_event_with_different_json_content_types() -> None: + test_cases = [ + ("application/vnd.api+json", {"key": "value"}), + ("text/json", {"key": "value"}), + ("application/json; charset=utf-8", {"key": "value"}), + ] + + for content_type, data in test_cases: + attributes = { + "id": "123", + "source": "source", + "type": "type", + "specversion": "1.0", + "datacontenttype": content_type, + } + event = CloudEvent(attributes=attributes, data=data) + formatter = JSONFormat() + result = formatter.write(event) + + assert b'"data": {"key": "value"}' in result + + +def test_read_cloud_event_with_string_data() -> None: + data = '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "data": "plain string data"}'.encode( + "utf-8" + ) + formatter = JSONFormat() + result = formatter.read(CloudEvent, data) + + assert result.get_data() == "plain string data" + + +def test_write_cloud_event_with_utc_timezone_z_suffix() -> None: + attributes = { + "id": "123", + "source": "source", + "type": "type", + "specversion": "1.0", + "time": datetime(2023, 10, 25, 17, 9, 19, 736166, tzinfo=timezone.utc), + } + event = CloudEvent(attributes=attributes, data=None) + formatter = JSONFormat() + result = formatter.write(event) + + assert b'"time": "2023-10-25T17:09:19.736166Z"' in result + + +def test_write_cloud_event_with_unicode_data() -> None: + attributes = { + "id": "123", + "source": "source", + "type": "type", + "specversion": "1.0", + } + event = CloudEvent(attributes=attributes, data="Hello 世界 🌍") + formatter = JSONFormat() + result = formatter.write(event) + + decoded = result.decode("utf-8") + assert '"data": "Hello' in decoded + assert "Hello" in decoded + + +def test_read_cloud_event_with_unicode_data() -> None: + data = '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "data": "Hello 世界 🌍"}'.encode( + "utf-8" + ) + formatter = JSONFormat() + result = formatter.read(CloudEvent, data) + + assert result.get_data() == "Hello 世界 🌍" + + +def test_read_cloud_event_from_string_input() -> None: + data = '{"id": "123", "source": "source", "type": "type", "specversion": "1.0"}' + formatter = JSONFormat() + result = formatter.read(CloudEvent, data) + + assert result.get_id() == "123" + assert result.get_source() == "source" diff --git a/tests/test_core/test_v03/__init__.py b/tests/test_core/test_v03/__init__.py new file mode 100644 index 00000000..09b419aa --- /dev/null +++ b/tests/test_core/test_v03/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Tests for CloudEvents v0.3 implementation.""" diff --git a/tests/test_core/test_v03/test_event.py b/tests/test_core/test_v03/test_event.py new file mode 100644 index 00000000..950308ed --- /dev/null +++ b/tests/test_core/test_v03/test_event.py @@ -0,0 +1,609 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from datetime import datetime, timezone +from typing import Any + +import pytest + +from cloudevents.core.exceptions import ( + CloudEventValidationError, + CustomExtensionAttributeError, + InvalidAttributeTypeError, + InvalidAttributeValueError, + MissingRequiredAttributeError, +) +from cloudevents.core.v03.event import CloudEvent + + +def test_missing_required_attributes() -> None: + with pytest.raises(CloudEventValidationError) as e: + CloudEvent({}) + + expected_errors = { + "source": [ + str(MissingRequiredAttributeError("source")), + str( + InvalidAttributeValueError( + attribute_name="source", + msg="Attribute 'source' must not be None or empty", + ) + ), + str(InvalidAttributeTypeError("source", str)), + ], + "type": [ + str(MissingRequiredAttributeError("type")), + str( + InvalidAttributeValueError( + attribute_name="type", + msg="Attribute 'type' must not be None or empty", + ) + ), + str(InvalidAttributeTypeError("type", str)), + ], + } + + actual_errors = { + key: [str(e) for e in value] for key, value in e.value.errors.items() + } + assert actual_errors == expected_errors + + +def test_invalid_specversion() -> None: + """Test that v0.3 CloudEvent rejects non-0.3 specversion""" + with pytest.raises(CloudEventValidationError) as e: + CloudEvent( + { + "id": "1", + "source": "/", + "type": "test", + "specversion": "1.0", # Wrong version! + } + ) + + assert "specversion" in e.value.errors + assert any("must be '0.3'" in str(err) for err in e.value.errors["specversion"]) + + +@pytest.mark.parametrize( + "time,expected_error", + [ + ( + "2023-10-25T17:09:19.736166Z", + {"time": [str(InvalidAttributeTypeError("time", datetime))]}, + ), + ( + datetime(2023, 10, 25, 17, 9, 19, 736166), + { + "time": [ + str( + InvalidAttributeValueError( + "time", "Attribute 'time' must be timezone aware" + ) + ) + ] + }, + ), + ( + 1, + {"time": [str(InvalidAttributeTypeError("time", datetime))]}, + ), + ], +) +def test_time_validation(time: Any, expected_error: dict) -> None: + with pytest.raises(CloudEventValidationError) as e: + CloudEvent( + { + "id": "1", + "source": "/", + "type": "test", + "specversion": "0.3", + "time": time, + } + ) + actual_errors = { + key: [str(e) for e in value] for key, value in e.value.errors.items() + } + assert actual_errors == expected_error + + +@pytest.mark.parametrize( + "subject,expected_error", + [ + ( + 1234, + {"subject": [str(InvalidAttributeTypeError("subject", str))]}, + ), + ( + "", + { + "subject": [ + str( + InvalidAttributeValueError( + "subject", "Attribute 'subject' must not be empty" + ) + ) + ] + }, + ), + ], +) +def test_subject_validation(subject: Any, expected_error: dict) -> None: + with pytest.raises(CloudEventValidationError) as e: + CloudEvent( + { + "id": "1", + "source": "/", + "type": "test", + "specversion": "0.3", + "subject": subject, + } + ) + + actual_errors = { + key: [str(e) for e in value] for key, value in e.value.errors.items() + } + assert actual_errors == expected_error + + +@pytest.mark.parametrize( + "datacontenttype,expected_error", + [ + ( + 1234, + { + "datacontenttype": [ + str(InvalidAttributeTypeError("datacontenttype", str)) + ] + }, + ), + ( + "", + { + "datacontenttype": [ + str( + InvalidAttributeValueError( + "datacontenttype", + "Attribute 'datacontenttype' must not be empty", + ) + ) + ] + }, + ), + ], +) +def test_datacontenttype_validation(datacontenttype: Any, expected_error: dict) -> None: + with pytest.raises(CloudEventValidationError) as e: + CloudEvent( + { + "id": "1", + "source": "/", + "type": "test", + "specversion": "0.3", + "datacontenttype": datacontenttype, + } + ) + + actual_errors = { + key: [str(e) for e in value] for key, value in e.value.errors.items() + } + assert actual_errors == expected_error + + +@pytest.mark.parametrize( + "datacontentencoding,expected_error", + [ + ( + 1234, + { + "datacontentencoding": [ + str(InvalidAttributeTypeError("datacontentencoding", str)) + ] + }, + ), + ( + "", + { + "datacontentencoding": [ + str( + InvalidAttributeValueError( + "datacontentencoding", + "Attribute 'datacontentencoding' must not be empty", + ) + ) + ] + }, + ), + ], +) +def test_datacontentencoding_validation( + datacontentencoding: Any, expected_error: dict +) -> None: + """Test v0.3 specific datacontentencoding attribute validation""" + with pytest.raises(CloudEventValidationError) as e: + CloudEvent( + { + "id": "1", + "source": "/", + "type": "test", + "specversion": "0.3", + "datacontentencoding": datacontentencoding, + } + ) + + actual_errors = { + key: [str(e) for e in value] for key, value in e.value.errors.items() + } + assert actual_errors == expected_error + + +@pytest.mark.parametrize( + "schemaurl,expected_error", + [ + ( + 1234, + {"schemaurl": [str(InvalidAttributeTypeError("schemaurl", str))]}, + ), + ( + "", + { + "schemaurl": [ + str( + InvalidAttributeValueError( + "schemaurl", "Attribute 'schemaurl' must not be empty" + ) + ) + ] + }, + ), + ], +) +def test_schemaurl_validation(schemaurl: Any, expected_error: dict) -> None: + """Test v0.3 specific schemaurl attribute validation""" + with pytest.raises(CloudEventValidationError) as e: + CloudEvent( + { + "id": "1", + "source": "/", + "type": "test", + "specversion": "0.3", + "schemaurl": schemaurl, + } + ) + + actual_errors = { + key: [str(e) for e in value] for key, value in e.value.errors.items() + } + assert actual_errors == expected_error + + +@pytest.mark.parametrize( + "attributes,expected_errors", + [ + ( + {"id": "", "source": "/", "type": "test"}, + { + "id": [ + str( + InvalidAttributeValueError( + attribute_name="id", + msg="Attribute 'id' must not be None or empty", + ) + ) + ] + }, + ), + ( + {"id": None, "source": "/", "type": "test"}, + { + "id": [ + str( + InvalidAttributeValueError( + attribute_name="id", + msg="Attribute 'id' must not be None or empty", + ) + ), + str( + InvalidAttributeTypeError( + attribute_name="id", expected_type=str + ) + ), + ] + }, + ), + ( + {"id": "1", "source": "", "type": "test"}, + { + "source": [ + str( + InvalidAttributeValueError( + attribute_name="source", + msg="Attribute 'source' must not be None or empty", + ) + ) + ] + }, + ), + ( + {"id": "1", "source": None, "type": "test"}, + { + "source": [ + str( + InvalidAttributeValueError( + attribute_name="source", + msg="Attribute 'source' must not be None or empty", + ) + ), + str( + InvalidAttributeTypeError( + attribute_name="source", expected_type=str + ) + ), + ] + }, + ), + ( + {"id": "1", "source": "/", "type": ""}, + { + "type": [ + str( + InvalidAttributeValueError( + attribute_name="type", + msg="Attribute 'type' must not be None or empty", + ) + ) + ] + }, + ), + ( + {"id": "1", "source": "/", "type": None}, + { + "type": [ + str( + InvalidAttributeValueError( + attribute_name="type", + msg="Attribute 'type' must not be None or empty", + ) + ), + str( + InvalidAttributeTypeError( + attribute_name="type", expected_type=str + ) + ), + ] + }, + ), + ], +) +def test_required_attributes_null_or_empty( + attributes: dict[str, Any], expected_errors: dict +) -> None: + with pytest.raises(CloudEventValidationError) as e: + CloudEvent(attributes=attributes) + + actual_errors = { + key: [str(e) for e in value] for key, value in e.value.errors.items() + } + for key, expected_msgs in expected_errors.items(): + assert key in actual_errors + assert actual_errors[key] == expected_msgs + + +@pytest.mark.parametrize( + "extension_name,expected_error", + [ + ( + "", + { + "": [ + str( + CustomExtensionAttributeError( + "", + "Extension attribute name must be at least 1 character long but was ''", + ) + ), + str( + CustomExtensionAttributeError( + "", + "Extension attribute '' should only contain lowercase letters and numbers", + ) + ), + ] + }, + ), + ( + "data", + { + "data": [ + str( + CustomExtensionAttributeError( + "data", + "Extension attribute 'data' is reserved and must not be used", + ) + ) + ] + }, + ), + ], +) +def test_custom_extension(extension_name: str, expected_error: dict) -> None: + with pytest.raises(CloudEventValidationError) as e: + CloudEvent( + { + "id": "1", + "source": "/", + "type": "test", + "specversion": "0.3", + extension_name: "value", + } + ) + + actual_errors = { + key: [str(e) for e in value] for key, value in e.value.errors.items() + } + assert actual_errors == expected_error + + +def test_long_extension_attribute_name() -> None: + # Verify that extension attribute names longer than 20 characters are allowed + long_name = "a" * 21 + event = CloudEvent( + { + "id": "1", + "source": "/", + "type": "test", + "specversion": "0.3", + long_name: "value", + } + ) + assert event.get_extension(long_name) == "value" + + +def test_default_specversion() -> None: + event = CloudEvent( + attributes={"source": "/source", "type": "test", "id": "1"}, + ) + assert event.get_specversion() == "0.3" + + +def test_default_id() -> None: + event = CloudEvent( + attributes={"source": "/source", "type": "test", "specversion": "0.3"}, + ) + assert isinstance(event.get_id(), str) + assert len(event.get_id()) == 36 # UUID4 format + + +def test_default_id_is_unique() -> None: + event1 = CloudEvent(attributes={"source": "/s", "type": "t"}) + event2 = CloudEvent(attributes={"source": "/s", "type": "t"}) + assert event1.get_id() != event2.get_id() + + +def test_default_time() -> None: + before = datetime.now(tz=timezone.utc) + event = CloudEvent( + attributes={"source": "/source", "type": "test", "specversion": "0.3"}, + ) + after = datetime.now(tz=timezone.utc) + assert event.get_time() is not None + assert before <= event.get_time() <= after + assert event.get_time().tzinfo is not None + + +def test_explicit_values_override_defaults() -> None: + custom_time = datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc) + event = CloudEvent( + attributes={ + "source": "/source", + "type": "test", + "specversion": "0.3", + "id": "my-custom-id", + "time": custom_time, + }, + ) + assert event.get_id() == "my-custom-id" + assert event.get_time() == custom_time + assert event.get_specversion() == "0.3" + + +def test_minimal_event_with_defaults() -> None: + event = CloudEvent( + attributes={"source": "/source", "type": "test"}, + ) + assert event.get_source() == "/source" + assert event.get_type() == "test" + assert event.get_specversion() == "0.3" + assert event.get_id() is not None + assert event.get_time() is not None + + +def test_cloud_event_v03_constructor() -> None: + """Test creating a v0.3 CloudEvent with all attributes""" + id = "1" + source = "/source" + type = "com.test.type" + specversion = "0.3" + datacontenttype = "application/json" + datacontentencoding = "base64" + schemaurl = "http://example.com/schema.json" + subject = "test_subject" + time = datetime.now(tz=timezone.utc) + data = {"key": "value"} + customextension = "customExtension" + + event = CloudEvent( + attributes={ + "id": id, + "source": source, + "type": type, + "specversion": specversion, + "datacontenttype": datacontenttype, + "datacontentencoding": datacontentencoding, + "schemaurl": schemaurl, + "subject": subject, + "time": time, + "customextension": customextension, + }, + data=data, + ) + + assert event.get_id() == id + assert event.get_source() == source + assert event.get_type() == type + assert event.get_specversion() == specversion + assert event.get_datacontenttype() == datacontenttype + assert event.get_datacontentencoding() == datacontentencoding + assert event.get_schemaurl() == schemaurl + assert event.get_subject() == subject + assert event.get_time() == time + assert event.get_extension("customextension") == customextension + assert event.get_data() == data + + +def test_get_dataschema_returns_schemaurl() -> None: + """Test that get_dataschema() returns schemaurl for v0.3 compatibility""" + event = CloudEvent( + attributes={ + "id": "1", + "source": "/source", + "type": "com.test.type", + "specversion": "0.3", + "schemaurl": "http://example.com/schema.json", + } + ) + + # get_dataschema should return the schemaurl value for compatibility + assert event.get_dataschema() == "http://example.com/schema.json" + assert event.get_schemaurl() == "http://example.com/schema.json" + + +def test_v03_minimal_event() -> None: + """Test creating a minimal v0.3 CloudEvent""" + event = CloudEvent( + attributes={ + "id": "test-123", + "source": "https://example.com/source", + "type": "com.example.test", + "specversion": "0.3", + } + ) + + assert event.get_id() == "test-123" + assert event.get_source() == "https://example.com/source" + assert event.get_type() == "com.example.test" + assert event.get_specversion() == "0.3" + assert event.get_data() is None + assert event.get_datacontentencoding() is None + assert event.get_schemaurl() is None diff --git a/tests/test_core/test_v03/test_http_bindings.py b/tests/test_core/test_v03/test_http_bindings.py new file mode 100644 index 00000000..13bcd592 --- /dev/null +++ b/tests/test_core/test_v03/test_http_bindings.py @@ -0,0 +1,511 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from datetime import datetime, timezone + +from cloudevents.core.bindings.http import ( + HTTPMessage, + from_binary, + from_binary_event, + from_http, + from_http_event, + from_structured, + from_structured_event, + to_binary, + to_structured, +) +from cloudevents.core.formats.json import JSONFormat +from cloudevents.core.v03.event import CloudEvent + + +def test_v03_to_binary_minimal() -> None: + """Test converting minimal v0.3 event to HTTP binary mode""" + event = CloudEvent( + attributes={ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + } + ) + + message = to_binary(event, JSONFormat()) + + assert "ce-specversion" in message.headers + assert message.headers["ce-specversion"] == "0.3" + assert "ce-type" in message.headers + assert "ce-source" in message.headers + assert "ce-id" in message.headers + + +def test_v03_to_binary_with_schemaurl() -> None: + """Test converting v0.3 event with schemaurl to HTTP binary mode""" + event = CloudEvent( + attributes={ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + "schemaurl": "https://example.com/schema.json", + } + ) + + message = to_binary(event, JSONFormat()) + + assert "ce-schemaurl" in message.headers + # URL should be percent-encoded + assert "https" in message.headers["ce-schemaurl"] + + +def test_v03_to_binary_with_datacontentencoding() -> None: + """Test converting v0.3 event with datacontentencoding to HTTP binary mode""" + event = CloudEvent( + attributes={ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + "datacontentencoding": "base64", + } + ) + + message = to_binary(event, JSONFormat()) + + assert "ce-datacontentencoding" in message.headers + assert message.headers["ce-datacontentencoding"] == "base64" + + +def test_v03_from_binary_minimal() -> None: + """Test parsing minimal v0.3 binary HTTP message""" + message = HTTPMessage( + headers={ + "ce-specversion": "0.3", + "ce-type": "com.example.test", + "ce-source": "/test", + "ce-id": "test-123", + }, + body=b"", + ) + + event = from_binary(message, JSONFormat(), CloudEvent) + + assert event.get_specversion() == "0.3" + assert event.get_type() == "com.example.test" + assert event.get_source() == "/test" + assert event.get_id() == "test-123" + + +def test_v03_from_binary_with_schemaurl() -> None: + """Test parsing v0.3 binary HTTP message with schemaurl""" + message = HTTPMessage( + headers={ + "ce-specversion": "0.3", + "ce-type": "com.example.test", + "ce-source": "/test", + "ce-id": "test-123", + "ce-schemaurl": "https://example.com/schema.json", + }, + body=b"", + ) + + event = from_binary(message, JSONFormat(), CloudEvent) + + assert event.get_schemaurl() == "https://example.com/schema.json" + + +def test_v03_from_binary_with_datacontentencoding() -> None: + """Test parsing v0.3 binary HTTP message with datacontentencoding""" + message = HTTPMessage( + headers={ + "ce-specversion": "0.3", + "ce-type": "com.example.test", + "ce-source": "/test", + "ce-id": "test-123", + "ce-datacontentencoding": "base64", + }, + body=b"", + ) + + event = from_binary(message, JSONFormat(), CloudEvent) + + assert event.get_datacontentencoding() == "base64" + + +def test_v03_binary_round_trip() -> None: + """Test v0.3 binary mode round-trip""" + original = CloudEvent( + attributes={ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + "subject": "test-subject", + "schemaurl": "https://example.com/schema.json", + "datacontenttype": "application/json", + }, + data={"message": "Hello", "count": 42}, + ) + + # Convert to binary + message = to_binary(original, JSONFormat()) + + # Parse back + parsed = from_binary(message, JSONFormat(), CloudEvent) + + assert parsed.get_specversion() == original.get_specversion() + assert parsed.get_type() == original.get_type() + assert parsed.get_source() == original.get_source() + assert parsed.get_id() == original.get_id() + assert parsed.get_subject() == original.get_subject() + assert parsed.get_schemaurl() == original.get_schemaurl() + assert parsed.get_datacontenttype() == original.get_datacontenttype() + assert parsed.get_data() == original.get_data() + + +def test_v03_to_structured_minimal() -> None: + """Test converting minimal v0.3 event to HTTP structured mode""" + event = CloudEvent( + attributes={ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + } + ) + + message = to_structured(event, JSONFormat()) + + assert message.headers["content-type"] == "application/cloudevents+json" + assert b'"specversion": "0.3"' in message.body + assert b'"type": "com.example.test"' in message.body + + +def test_v03_to_structured_with_schemaurl() -> None: + """Test converting v0.3 event with schemaurl to structured mode""" + event = CloudEvent( + attributes={ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + "schemaurl": "https://example.com/schema.json", + } + ) + + message = to_structured(event, JSONFormat()) + + assert b'"schemaurl": "https://example.com/schema.json"' in message.body + + +def test_v03_from_structured_minimal() -> None: + """Test parsing minimal v0.3 structured HTTP message""" + message = HTTPMessage( + headers={"content-type": "application/cloudevents+json"}, + body=b'{"specversion": "0.3", "type": "com.example.test", "source": "/test", "id": "test-123"}', + ) + + event = from_structured(message, JSONFormat(), CloudEvent) + + assert event.get_specversion() == "0.3" + assert event.get_type() == "com.example.test" + assert event.get_source() == "/test" + assert event.get_id() == "test-123" + + +def test_v03_from_structured_with_schemaurl() -> None: + """Test parsing v0.3 structured HTTP message with schemaurl""" + message = HTTPMessage( + headers={"content-type": "application/cloudevents+json"}, + body=b'{"specversion": "0.3", "type": "com.example.test", "source": "/test", "id": "test-123", "schemaurl": "https://example.com/schema.json"}', + ) + + event = from_structured(message, JSONFormat(), CloudEvent) + + assert event.get_schemaurl() == "https://example.com/schema.json" + + +def test_v03_structured_round_trip() -> None: + """Test v0.3 structured mode round-trip""" + original = CloudEvent( + attributes={ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + "subject": "test-subject", + "schemaurl": "https://example.com/schema.json", + "datacontenttype": "application/json", + }, + data={"message": "Hello", "count": 42}, + ) + + # Convert to structured + message = to_structured(original, JSONFormat()) + + # Parse back + parsed = from_structured(message, JSONFormat(), CloudEvent) + + assert parsed.get_specversion() == original.get_specversion() + assert parsed.get_type() == original.get_type() + assert parsed.get_source() == original.get_source() + assert parsed.get_id() == original.get_id() + assert parsed.get_subject() == original.get_subject() + assert parsed.get_schemaurl() == original.get_schemaurl() + assert parsed.get_datacontenttype() == original.get_datacontenttype() + assert parsed.get_data() == original.get_data() + + +def test_v03_from_http_auto_detects_binary() -> None: + """Test that from_http auto-detects v0.3 binary mode""" + message = HTTPMessage( + headers={ + "ce-specversion": "0.3", + "ce-type": "com.example.test", + "ce-source": "/test", + "ce-id": "test-123", + }, + body=b"", + ) + + event = from_http(message, JSONFormat(), CloudEvent) + + assert event.get_specversion() == "0.3" + assert event.get_type() == "com.example.test" + + +def test_v03_from_http_auto_detects_structured() -> None: + """Test that from_http auto-detects v0.3 structured mode""" + message = HTTPMessage( + headers={"content-type": "application/cloudevents+json"}, + body=b'{"specversion": "0.3", "type": "com.example.test", "source": "/test", "id": "test-123"}', + ) + + event = from_http(message, JSONFormat(), CloudEvent) + + assert event.get_specversion() == "0.3" + assert event.get_type() == "com.example.test" + + +def test_v03_auto_detect_version_from_binary_headers() -> None: + """Test auto-detection of v0.3 from binary mode headers""" + message = HTTPMessage( + headers={ + "ce-specversion": "0.3", + "ce-type": "com.example.test", + "ce-source": "/test", + "ce-id": "test-123", + }, + body=b"", + ) + + # Don't provide event_factory, let it auto-detect + event = from_binary(message, JSONFormat()) + + assert isinstance(event, CloudEvent) + assert event.get_specversion() == "0.3" + + +def test_v03_auto_detect_version_from_structured_body() -> None: + """Test auto-detection of v0.3 from structured mode body""" + message = HTTPMessage( + headers={"content-type": "application/cloudevents+json"}, + body=b'{"specversion": "0.3", "type": "com.example.test", "source": "/test", "id": "test-123"}', + ) + + # Don't provide event_factory, let it auto-detect + event = from_structured(message, JSONFormat()) + + assert isinstance(event, CloudEvent) + assert event.get_specversion() == "0.3" + + +def test_v03_from_http_auto_detect_version_binary() -> None: + """Test from_http auto-detects v0.3 with no explicit factory""" + message = HTTPMessage( + headers={ + "ce-specversion": "0.3", + "ce-type": "com.example.test", + "ce-source": "/test", + "ce-id": "test-123", + }, + body=b"", + ) + + # Auto-detect both mode and version + event = from_http(message, JSONFormat()) + + assert isinstance(event, CloudEvent) + assert event.get_specversion() == "0.3" + + +def test_v03_from_http_auto_detect_version_structured() -> None: + """Test from_http auto-detects v0.3 structured with no explicit factory""" + message = HTTPMessage( + headers={"content-type": "application/cloudevents+json"}, + body=b'{"specversion": "0.3", "type": "com.example.test", "source": "/test", "id": "test-123"}', + ) + + # Auto-detect both mode and version + event = from_http(message, JSONFormat()) + + assert isinstance(event, CloudEvent) + assert event.get_specversion() == "0.3" + + +def test_v03_convenience_wrappers_binary() -> None: + """Test convenience wrapper functions with v0.3 binary mode""" + message = HTTPMessage( + headers={ + "ce-specversion": "0.3", + "ce-type": "com.example.test", + "ce-source": "/test", + "ce-id": "test-123", + }, + body=b"", + ) + + event = from_binary_event(message) + + assert isinstance(event, CloudEvent) + assert event.get_specversion() == "0.3" + + +def test_v03_convenience_wrappers_structured() -> None: + """Test convenience wrapper functions with v0.3 structured mode""" + message = HTTPMessage( + headers={"content-type": "application/cloudevents+json"}, + body=b'{"specversion": "0.3", "type": "com.example.test", "source": "/test", "id": "test-123"}', + ) + + event = from_structured_event(message) + + assert isinstance(event, CloudEvent) + assert event.get_specversion() == "0.3" + + +def test_v03_convenience_wrappers_from_http() -> None: + """Test from_http_event convenience wrapper with v0.3""" + # Binary mode + binary_message = HTTPMessage( + headers={ + "ce-specversion": "0.3", + "ce-type": "com.example.test", + "ce-source": "/test", + "ce-id": "test-123", + }, + body=b"", + ) + + event1 = from_http_event(binary_message) + assert event1.get_specversion() == "0.3" + + # Structured mode + structured_message = HTTPMessage( + headers={"content-type": "application/cloudevents+json"}, + body=b'{"specversion": "0.3", "type": "com.example.test", "source": "/test", "id": "test-123"}', + ) + + event2 = from_http_event(structured_message) + assert event2.get_specversion() == "0.3" + + +def test_v03_binary_with_time() -> None: + """Test v0.3 binary mode with time attribute""" + dt = datetime(2023, 6, 15, 14, 30, 45, tzinfo=timezone.utc) + + event = CloudEvent( + attributes={ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + "time": dt, + } + ) + + message = to_binary(event, JSONFormat()) + parsed = from_binary(message, JSONFormat(), CloudEvent) + + assert parsed.get_time() is not None + assert parsed.get_time().year == 2023 + + +def test_v03_complete_binary_event() -> None: + """Test v0.3 complete event with all attributes in binary mode""" + dt = datetime(2023, 6, 15, 14, 30, 45, tzinfo=timezone.utc) + + event = CloudEvent( + attributes={ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + "time": dt, + "subject": "test-subject", + "datacontenttype": "application/json", + "datacontentencoding": "base64", + "schemaurl": "https://example.com/schema.json", + "customext": "custom-value", + }, + data={"message": "Hello World!"}, + ) + + message = to_binary(event, JSONFormat()) + parsed = from_binary(message, JSONFormat()) # Auto-detect + + assert isinstance(parsed, CloudEvent) + assert parsed.get_specversion() == "0.3" + assert parsed.get_type() == "com.example.test" + assert parsed.get_source() == "/test" + assert parsed.get_id() == "test-123" + assert parsed.get_subject() == "test-subject" + assert parsed.get_datacontenttype() == "application/json" + assert parsed.get_datacontentencoding() == "base64" + assert parsed.get_schemaurl() == "https://example.com/schema.json" + assert parsed.get_extension("customext") == "custom-value" + assert parsed.get_data() == {"message": "Hello World!"} + + +def test_v03_complete_structured_event() -> None: + """Test v0.3 complete event with all attributes in structured mode""" + dt = datetime(2023, 6, 15, 14, 30, 45, tzinfo=timezone.utc) + + event = CloudEvent( + attributes={ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + "time": dt, + "subject": "test-subject", + "datacontenttype": "application/json", + "schemaurl": "https://example.com/schema.json", + "customext": "custom-value", + }, + data={"message": "Hello World!"}, + ) + + message = to_structured(event, JSONFormat()) + parsed = from_structured(message, JSONFormat()) # Auto-detect + + assert isinstance(parsed, CloudEvent) + assert parsed.get_specversion() == "0.3" + assert parsed.get_type() == "com.example.test" + assert parsed.get_source() == "/test" + assert parsed.get_id() == "test-123" + assert parsed.get_subject() == "test-subject" + assert parsed.get_datacontenttype() == "application/json" + assert parsed.get_schemaurl() == "https://example.com/schema.json" + assert parsed.get_extension("customext") == "custom-value" + assert parsed.get_data() == {"message": "Hello World!"} diff --git a/tests/test_core/test_v03/test_json_format.py b/tests/test_core/test_v03/test_json_format.py new file mode 100644 index 00000000..f863500a --- /dev/null +++ b/tests/test_core/test_v03/test_json_format.py @@ -0,0 +1,324 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import base64 +from datetime import datetime, timezone + +from cloudevents.core.formats.json import JSONFormat +from cloudevents.core.v03.event import CloudEvent + + +def test_v03_json_read_minimal() -> None: + """Test reading a minimal v0.3 CloudEvent from JSON""" + json_data = b"""{ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123" + }""" + + format = JSONFormat() + event = format.read(CloudEvent, json_data) + + assert event.get_specversion() == "0.3" + assert event.get_type() == "com.example.test" + assert event.get_source() == "/test" + assert event.get_id() == "test-123" + assert event.get_data() is None + + +def test_v03_json_write_minimal() -> None: + """Test writing a minimal v0.3 CloudEvent to JSON""" + event = CloudEvent( + attributes={ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + } + ) + + format = JSONFormat() + json_bytes = format.write(event) + json_str = json_bytes.decode("utf-8") + + assert '"specversion": "0.3"' in json_str + assert '"type": "com.example.test"' in json_str + assert '"source": "/test"' in json_str + assert '"id": "test-123"' in json_str + + +def test_v03_json_with_schemaurl() -> None: + """Test v0.3 schemaurl attribute in JSON""" + json_data = b"""{ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + "schemaurl": "https://example.com/schema.json" + }""" + + format = JSONFormat() + event = format.read(CloudEvent, json_data) + + assert event.get_schemaurl() == "https://example.com/schema.json" + assert event.get_dataschema() == "https://example.com/schema.json" + + +def test_v03_json_write_with_schemaurl() -> None: + """Test writing v0.3 event with schemaurl to JSON""" + event = CloudEvent( + attributes={ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + "schemaurl": "https://example.com/schema.json", + } + ) + + format = JSONFormat() + json_bytes = format.write(event) + json_str = json_bytes.decode("utf-8") + + assert '"schemaurl": "https://example.com/schema.json"' in json_str + + +def test_v03_json_with_datacontentencoding_base64() -> None: + """Test v0.3 datacontentencoding with base64 encoded data""" + # In v0.3, when datacontentencoding is base64, the data field contains base64 string + original_data = b"Hello World!" + base64_data = base64.b64encode(original_data).decode("utf-8") + + json_data = f'''{{ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + "datacontentencoding": "base64", + "data": "{base64_data}" + }}'''.encode("utf-8") + + format = JSONFormat() + event = format.read(CloudEvent, json_data) + + assert event.get_datacontentencoding() == "base64" + assert event.get_data() == original_data # Should be decoded + + +def test_v03_json_write_binary_data_with_base64() -> None: + """Test writing v0.3 event with binary data (uses datacontentencoding)""" + binary_data = b"Hello World!" + + event = CloudEvent( + attributes={ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + }, + data=binary_data, + ) + + format = JSONFormat() + json_bytes = format.write(event) + json_str = json_bytes.decode("utf-8") + + # v0.3 should use datacontentencoding with base64-encoded data field + assert '"datacontentencoding": "base64"' in json_str + assert '"data"' in json_str + assert '"data_base64"' not in json_str # v1.0 field should not be present + + # Verify we can read it back + event_read = format.read(CloudEvent, json_bytes) + assert event_read.get_data() == binary_data + + +def test_v03_json_round_trip_with_binary_data() -> None: + """Test complete round-trip of v0.3 event with binary data""" + original_data = b"\x00\x01\x02\x03\x04\x05" + + event = CloudEvent( + attributes={ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + "datacontenttype": "application/octet-stream", + }, + data=original_data, + ) + + format = JSONFormat() + + # Write to JSON + json_bytes = format.write(event) + + # Read back + event_read = format.read(CloudEvent, json_bytes) + + assert event_read.get_specversion() == "0.3" + assert event_read.get_data() == original_data + assert event_read.get_datacontentencoding() == "base64" + + +def test_v03_json_with_dict_data() -> None: + """Test v0.3 event with JSON dict data""" + json_data = b"""{ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + "datacontenttype": "application/json", + "data": {"message": "Hello", "count": 42} + }""" + + format = JSONFormat() + event = format.read(CloudEvent, json_data) + + data = event.get_data() + assert isinstance(data, dict) + assert data["message"] == "Hello" + assert data["count"] == 42 + + +def test_v03_json_write_with_dict_data() -> None: + """Test writing v0.3 event with dict data""" + event = CloudEvent( + attributes={ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + "datacontenttype": "application/json", + }, + data={"message": "Hello", "count": 42}, + ) + + format = JSONFormat() + json_bytes = format.write(event) + json_str = json_bytes.decode("utf-8") + + assert ( + '"data": {"message": "Hello", "count": 42}' in json_str + or '"data": {"count": 42, "message": "Hello"}' in json_str + ) + + +def test_v03_json_with_time() -> None: + """Test v0.3 event with time attribute""" + json_data = b"""{ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + "time": "2023-06-15T14:30:45Z" + }""" + + format = JSONFormat() + event = format.read(CloudEvent, json_data) + + time = event.get_time() + assert isinstance(time, datetime) + assert time.year == 2023 + assert time.month == 6 + assert time.day == 15 + + +def test_v03_json_write_with_time() -> None: + """Test writing v0.3 event with time""" + dt = datetime(2023, 6, 15, 14, 30, 45, tzinfo=timezone.utc) + + event = CloudEvent( + attributes={ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + "time": dt, + } + ) + + format = JSONFormat() + json_bytes = format.write(event) + json_str = json_bytes.decode("utf-8") + + assert '"time": "2023-06-15T14:30:45Z"' in json_str + + +def test_v03_json_complete_event() -> None: + """Test v0.3 event with all optional attributes""" + json_data = b"""{ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + "time": "2023-06-15T14:30:45Z", + "subject": "test-subject", + "datacontenttype": "application/json", + "schemaurl": "https://example.com/schema.json", + "customext": "custom-value", + "data": {"message": "Hello"} + }""" + + format = JSONFormat() + event = format.read(CloudEvent, json_data) + + assert event.get_specversion() == "0.3" + assert event.get_type() == "com.example.test" + assert event.get_source() == "/test" + assert event.get_id() == "test-123" + assert event.get_subject() == "test-subject" + assert event.get_datacontenttype() == "application/json" + assert event.get_schemaurl() == "https://example.com/schema.json" + assert event.get_extension("customext") == "custom-value" + assert event.get_data() == {"message": "Hello"} + + +def test_v03_json_round_trip_complete() -> None: + """Test complete round-trip of v0.3 event with all attributes""" + dt = datetime(2023, 6, 15, 14, 30, 45, tzinfo=timezone.utc) + + event = CloudEvent( + attributes={ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + "time": dt, + "subject": "test-subject", + "datacontenttype": "application/json", + "schemaurl": "https://example.com/schema.json", + "customext": "custom-value", + }, + data={"message": "Hello", "count": 42}, + ) + + format = JSONFormat() + + # Write to JSON + json_bytes = format.write(event) + + # Read back + event_read = format.read(CloudEvent, json_bytes) + + assert event_read.get_specversion() == event.get_specversion() + assert event_read.get_type() == event.get_type() + assert event_read.get_source() == event.get_source() + assert event_read.get_id() == event.get_id() + assert event_read.get_subject() == event.get_subject() + assert event_read.get_datacontenttype() == event.get_datacontenttype() + assert event_read.get_schemaurl() == event.get_schemaurl() + assert event_read.get_extension("customext") == event.get_extension("customext") + assert event_read.get_data() == event.get_data() diff --git a/tests/test_core/test_v1/__init__.py b/tests/test_core/test_v1/__init__.py new file mode 100644 index 00000000..8043675e --- /dev/null +++ b/tests/test_core/test_v1/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. diff --git a/tests/test_core/test_v1/test_event.py b/tests/test_core/test_v1/test_event.py new file mode 100644 index 00000000..7d283216 --- /dev/null +++ b/tests/test_core/test_v1/test_event.py @@ -0,0 +1,504 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from datetime import datetime, timezone +from typing import Any + +import pytest + +from cloudevents.core.exceptions import ( + CloudEventValidationError, + CustomExtensionAttributeError, + InvalidAttributeTypeError, + InvalidAttributeValueError, + MissingRequiredAttributeError, +) +from cloudevents.core.v1.event import CloudEvent + + +def test_missing_required_attributes() -> None: + with pytest.raises(CloudEventValidationError) as e: + CloudEvent({}) + + expected_errors = { + "source": [ + str(MissingRequiredAttributeError("source")), + str( + InvalidAttributeValueError( + attribute_name="source", + msg="Attribute 'source' must not be None or empty", + ) + ), + str(InvalidAttributeTypeError("source", str)), + ], + "type": [ + str(MissingRequiredAttributeError("type")), + str( + InvalidAttributeValueError( + attribute_name="type", + msg="Attribute 'type' must not be None or empty", + ) + ), + str(InvalidAttributeTypeError("type", str)), + ], + } + + actual_errors = { + key: [str(e) for e in value] for key, value in e.value.errors.items() + } + assert actual_errors == expected_errors + + +@pytest.mark.parametrize( + "time,expected_error", + [ + ( + "2023-10-25T17:09:19.736166Z", + {"time": [str(InvalidAttributeTypeError("time", datetime))]}, + ), + ( + datetime(2023, 10, 25, 17, 9, 19, 736166), + { + "time": [ + str( + InvalidAttributeValueError( + "time", "Attribute 'time' must be timezone aware" + ) + ) + ] + }, + ), + ( + 1, + {"time": [str(InvalidAttributeTypeError("time", datetime))]}, + ), + ], +) +def test_time_validation(time: Any, expected_error: dict) -> None: + with pytest.raises(CloudEventValidationError) as e: + CloudEvent( + { + "id": "1", + "source": "/", + "type": "test", + "specversion": "1.0", + "time": time, + } + ) + actual_errors = { + key: [str(e) for e in value] for key, value in e.value.errors.items() + } + assert actual_errors == expected_error + + +@pytest.mark.parametrize( + "subject,expected_error", + [ + ( + 1234, + {"subject": [str(InvalidAttributeTypeError("subject", str))]}, + ), + ( + "", + { + "subject": [ + str( + InvalidAttributeValueError( + "subject", "Attribute 'subject' must not be empty" + ) + ) + ] + }, + ), + ], +) +def test_subject_validation(subject: Any, expected_error: dict) -> None: + with pytest.raises(CloudEventValidationError) as e: + CloudEvent( + { + "id": "1", + "source": "/", + "type": "test", + "specversion": "1.0", + "subject": subject, + } + ) + + actual_errors = { + key: [str(e) for e in value] for key, value in e.value.errors.items() + } + assert actual_errors == expected_error + + +@pytest.mark.parametrize( + "datacontenttype,expected_error", + [ + ( + 1234, + { + "datacontenttype": [ + str(InvalidAttributeTypeError("datacontenttype", str)) + ] + }, + ), + ( + "", + { + "datacontenttype": [ + str( + InvalidAttributeValueError( + "datacontenttype", + "Attribute 'datacontenttype' must not be empty", + ) + ) + ] + }, + ), + ], +) +def test_datacontenttype_validation(datacontenttype: Any, expected_error: dict) -> None: + with pytest.raises(CloudEventValidationError) as e: + CloudEvent( + { + "id": "1", + "source": "/", + "type": "test", + "specversion": "1.0", + "datacontenttype": datacontenttype, + } + ) + + actual_errors = { + key: [str(e) for e in value] for key, value in e.value.errors.items() + } + assert actual_errors == expected_error + + +@pytest.mark.parametrize( + "dataschema,expected_error", + [ + ( + 1234, + {"dataschema": [str(InvalidAttributeTypeError("dataschema", str))]}, + ), + ( + "", + { + "dataschema": [ + str( + InvalidAttributeValueError( + "dataschema", "Attribute 'dataschema' must not be empty" + ) + ) + ] + }, + ), + ], +) +def test_dataschema_validation(dataschema: Any, expected_error: dict) -> None: + with pytest.raises(CloudEventValidationError) as e: + CloudEvent( + { + "id": "1", + "source": "/", + "type": "test", + "specversion": "1.0", + "dataschema": dataschema, + } + ) + + actual_errors = { + key: [str(e) for e in value] for key, value in e.value.errors.items() + } + assert actual_errors == expected_error + + +@pytest.mark.parametrize( + "attributes,expected_errors", + [ + ( + {"id": "", "source": "/", "type": "test"}, + { + "id": [ + str( + InvalidAttributeValueError( + attribute_name="id", + msg="Attribute 'id' must not be None or empty", + ) + ) + ] + }, + ), + ( + {"id": None, "source": "/", "type": "test"}, + { + "id": [ + str( + InvalidAttributeValueError( + attribute_name="id", + msg="Attribute 'id' must not be None or empty", + ) + ), + str( + InvalidAttributeTypeError( + attribute_name="id", expected_type=str + ) + ), + ] + }, + ), + ( + {"id": "1", "source": "", "type": "test"}, + { + "source": [ + str( + InvalidAttributeValueError( + attribute_name="source", + msg="Attribute 'source' must not be None or empty", + ) + ) + ] + }, + ), + ( + {"id": "1", "source": None, "type": "test"}, + { + "source": [ + str( + InvalidAttributeValueError( + attribute_name="source", + msg="Attribute 'source' must not be None or empty", + ) + ), + str( + InvalidAttributeTypeError( + attribute_name="source", expected_type=str + ) + ), + ] + }, + ), + ( + {"id": "1", "source": "/", "type": ""}, + { + "type": [ + str( + InvalidAttributeValueError( + attribute_name="type", + msg="Attribute 'type' must not be None or empty", + ) + ) + ] + }, + ), + ( + {"id": "1", "source": "/", "type": None}, + { + "type": [ + str( + InvalidAttributeValueError( + attribute_name="type", + msg="Attribute 'type' must not be None or empty", + ) + ), + str( + InvalidAttributeTypeError( + attribute_name="type", expected_type=str + ) + ), + ] + }, + ), + ], +) +def test_required_attributes_null_or_empty( + attributes: dict[str, Any], expected_errors: dict +) -> None: + with pytest.raises(CloudEventValidationError) as e: + CloudEvent(attributes=attributes) + + actual_errors = { + key: [str(e) for e in value] for key, value in e.value.errors.items() + } + for key, expected_msgs in expected_errors.items(): + assert key in actual_errors + assert actual_errors[key] == expected_msgs + + +@pytest.mark.parametrize( + "extension_name,expected_error", + [ + ( + "", + { + "": [ + str( + CustomExtensionAttributeError( + "", + "Extension attribute name must be at least 1 character long but was ''", + ) + ), + str( + CustomExtensionAttributeError( + "", + "Extension attribute '' should only contain lowercase letters and numbers", + ) + ), + ] + }, + ), + ( + "data", + { + "data": [ + str( + CustomExtensionAttributeError( + "data", + "Extension attribute 'data' is reserved and must not be used", + ) + ) + ] + }, + ), + ], +) +def test_custom_extension(extension_name: str, expected_error: dict) -> None: + with pytest.raises(CloudEventValidationError) as e: + CloudEvent( + { + "id": "1", + "source": "/", + "type": "test", + "specversion": "1.0", + extension_name: "value", + } + ) + + actual_errors = { + key: [str(e) for e in value] for key, value in e.value.errors.items() + } + assert actual_errors == expected_error + + +def test_long_extension_attribute_name() -> None: + # Verify that extension attribute names longer than 20 characters are allowed + long_name = "a" * 21 + event = CloudEvent( + { + "id": "1", + "source": "/", + "type": "test", + "specversion": "1.0", + long_name: "value", + } + ) + assert event.get_extension(long_name) == "value" + + +def test_default_specversion() -> None: + event = CloudEvent( + attributes={"source": "/source", "type": "test", "id": "1"}, + ) + assert event.get_specversion() == "1.0" + + +def test_default_id() -> None: + event = CloudEvent( + attributes={"source": "/source", "type": "test", "specversion": "1.0"}, + ) + assert isinstance(event.get_id(), str) + assert len(event.get_id()) == 36 # UUID4 format + + +def test_default_id_is_unique() -> None: + event1 = CloudEvent(attributes={"source": "/s", "type": "t"}) + event2 = CloudEvent(attributes={"source": "/s", "type": "t"}) + assert event1.get_id() != event2.get_id() + + +def test_default_time() -> None: + before = datetime.now(tz=timezone.utc) + event = CloudEvent( + attributes={"source": "/source", "type": "test", "specversion": "1.0"}, + ) + after = datetime.now(tz=timezone.utc) + assert event.get_time() is not None + assert before <= event.get_time() <= after + assert event.get_time().tzinfo is not None + + +def test_explicit_values_override_defaults() -> None: + custom_time = datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc) + event = CloudEvent( + attributes={ + "source": "/source", + "type": "test", + "specversion": "1.0", + "id": "my-custom-id", + "time": custom_time, + }, + ) + assert event.get_id() == "my-custom-id" + assert event.get_time() == custom_time + assert event.get_specversion() == "1.0" + + +def test_minimal_event_with_defaults() -> None: + event = CloudEvent( + attributes={"source": "/source", "type": "test"}, + ) + assert event.get_source() == "/source" + assert event.get_type() == "test" + assert event.get_specversion() == "1.0" + assert event.get_id() is not None + assert event.get_time() is not None + + +def test_cloud_event_constructor() -> None: + id = "1" + source = "/source" + type = "com.test.type" + specversion = "1.0" + datacontenttype = "application/json" + dataschema = "http://example.com/schema" + subject = "test_subject" + time = datetime.now(tz=timezone.utc) + data = {"key": "value"} + customextension = "customExtension" + + event = CloudEvent( + attributes={ + "id": id, + "source": source, + "type": type, + "specversion": specversion, + "datacontenttype": datacontenttype, + "dataschema": dataschema, + "subject": subject, + "time": time, + "customextension": customextension, + }, + data=data, + ) + + assert event.get_id() == id + assert event.get_source() == source + assert event.get_type() == type + assert event.get_specversion() == specversion + assert event.get_datacontenttype() == datacontenttype + assert event.get_dataschema() == dataschema + assert event.get_subject() == subject + assert event.get_time() == time + assert event.get_extension("customextension") == customextension + assert event.get_data() == data diff --git a/tests/test_v1_compat/__init__.py b/tests/test_v1_compat/__init__.py new file mode 100644 index 00000000..8043675e --- /dev/null +++ b/tests/test_v1_compat/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. diff --git a/tests/test_v1_compat/conftest.py b/tests/test_v1_compat/conftest.py new file mode 100644 index 00000000..9da54674 --- /dev/null +++ b/tests/test_v1_compat/conftest.py @@ -0,0 +1,27 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import sys + +_PYDANTIC_TEST_FILES = { + "test_pydantic_events.py", + "test_pydantic_cloudevent.py", + "test_pydantic_conversions.py", +} + + +def pytest_ignore_collect(collection_path, config): + if sys.version_info >= (3, 14) and collection_path.name in _PYDANTIC_TEST_FILES: + return True + return None diff --git a/cloudevents/tests/data.py b/tests/test_v1_compat/data.py similarity index 97% rename from cloudevents/tests/data.py rename to tests/test_v1_compat/data.py index f5b0ea33..33eeddb9 100644 --- a/cloudevents/tests/data.py +++ b/tests/test_v1_compat/data.py @@ -12,7 +12,7 @@ # License for the specific language governing permissions and limitations # under the License. -from cloudevents.sdk.event import v1, v03 +from cloudevents.v1.sdk.event import v03, v1 content_type = "application/json" ce_type = "word.found.exclamation" diff --git a/cloudevents/tests/test_backwards_compatability.py b/tests/test_v1_compat/test_backwards_compatability.py similarity index 63% rename from cloudevents/tests/test_backwards_compatability.py rename to tests/test_v1_compat/test_backwards_compatability.py index 0a20f4cf..e5cc12f2 100644 --- a/cloudevents/tests/test_backwards_compatability.py +++ b/tests/test_v1_compat/test_backwards_compatability.py @@ -13,8 +13,8 @@ # under the License. import pytest -from cloudevents.conversion import _best_effort_serialize_to_json -from cloudevents.http import CloudEvent +from cloudevents.v1.conversion import _best_effort_serialize_to_json +from cloudevents.v1.http import CloudEvent @pytest.fixture() @@ -23,10 +23,10 @@ def dummy_event(): def test_json_methods(dummy_event): - from cloudevents.conversion import to_json - from cloudevents.http.conversion import from_json - from cloudevents.http.json_methods import from_json as deprecated_from_json - from cloudevents.http.json_methods import to_json as deprecated_to_json + from cloudevents.v1.conversion import to_json + from cloudevents.v1.http.conversion import from_json + from cloudevents.v1.http.json_methods import from_json as deprecated_from_json + from cloudevents.v1.http.json_methods import to_json as deprecated_to_json assert from_json(to_json(dummy_event)) == deprecated_from_json( deprecated_to_json(dummy_event) @@ -34,10 +34,12 @@ def test_json_methods(dummy_event): def test_http_methods(dummy_event): - from cloudevents.http import from_http, to_binary, to_structured - from cloudevents.http.http_methods import from_http as deprecated_from_http - from cloudevents.http.http_methods import to_binary as deprecated_to_binary - from cloudevents.http.http_methods import to_structured as deprecated_to_structured + from cloudevents.v1.http import from_http, to_binary, to_structured + from cloudevents.v1.http.http_methods import from_http as deprecated_from_http + from cloudevents.v1.http.http_methods import to_binary as deprecated_to_binary + from cloudevents.v1.http.http_methods import ( + to_structured as deprecated_to_structured, + ) assert from_http(*to_binary(dummy_event)) == deprecated_from_http( *deprecated_to_binary(dummy_event) @@ -48,17 +50,17 @@ def test_http_methods(dummy_event): def test_util(): - from cloudevents.http.util import default_marshaller # noqa + from cloudevents.v1.http.util import default_marshaller # noqa assert _best_effort_serialize_to_json(None) == default_marshaller(None) def test_event_type(): - from cloudevents.http.event_type import is_binary, is_structured # noqa + from cloudevents.v1.http.event_type import is_binary, is_structured # noqa def test_http_module_imports(): - from cloudevents.http import ( # noqa + from cloudevents.v1.http import ( # noqa CloudEvent, from_dict, from_http, diff --git a/cloudevents/tests/test_base_events.py b/tests/test_v1_compat/test_base_events.py similarity index 92% rename from cloudevents/tests/test_base_events.py rename to tests/test_v1_compat/test_base_events.py index 8eb83d44..ff10abff 100644 --- a/cloudevents/tests/test_base_events.py +++ b/tests/test_v1_compat/test_base_events.py @@ -14,8 +14,8 @@ import pytest -import cloudevents.exceptions as cloud_exceptions -from cloudevents.sdk.event import v1, v03 +import cloudevents.v1.exceptions as cloud_exceptions +from cloudevents.v1.sdk.event import v03, v1 @pytest.mark.parametrize("event_class", [v1.Event, v03.Event]) diff --git a/cloudevents/tests/test_converters.py b/tests/test_v1_compat/test_converters.py similarity index 93% rename from cloudevents/tests/test_converters.py rename to tests/test_v1_compat/test_converters.py index 50d783b5..78f7aa2f 100644 --- a/cloudevents/tests/test_converters.py +++ b/tests/test_v1_compat/test_converters.py @@ -14,8 +14,8 @@ import pytest -from cloudevents.sdk import exceptions -from cloudevents.sdk.converters import base, binary +from cloudevents.v1.sdk import exceptions +from cloudevents.v1.sdk.converters import base, binary def test_binary_converter_raise_unsupported(): diff --git a/cloudevents/tests/test_data_encaps_refs.py b/tests/test_v1_compat/test_data_encaps_refs.py similarity index 96% rename from cloudevents/tests/test_data_encaps_refs.py rename to tests/test_v1_compat/test_data_encaps_refs.py index 02405a93..c82254e5 100644 --- a/cloudevents/tests/test_data_encaps_refs.py +++ b/tests/test_v1_compat/test_data_encaps_refs.py @@ -17,9 +17,9 @@ import pytest -from cloudevents.sdk import converters, marshaller -from cloudevents.sdk.event import v1, v03 -from cloudevents.tests import data +from cloudevents.v1.sdk import converters, marshaller +from cloudevents.v1.sdk.event import v03, v1 +from test_v1_compat import data @pytest.mark.parametrize("event_class", [v03.Event, v1.Event]) diff --git a/cloudevents/tests/test_deprecated_functions.py b/tests/test_v1_compat/test_deprecated_functions.py similarity index 97% rename from cloudevents/tests/test_deprecated_functions.py rename to tests/test_v1_compat/test_deprecated_functions.py index a99f6247..64ffbf27 100644 --- a/cloudevents/tests/test_deprecated_functions.py +++ b/tests/test_v1_compat/test_deprecated_functions.py @@ -14,7 +14,7 @@ import pytest -from cloudevents.http import ( +from cloudevents.v1.http import ( CloudEvent, to_binary, to_binary_http, diff --git a/cloudevents/tests/test_event_extensions.py b/tests/test_v1_compat/test_event_extensions.py similarity index 97% rename from cloudevents/tests/test_event_extensions.py rename to tests/test_v1_compat/test_event_extensions.py index eea8edfa..52059acd 100644 --- a/cloudevents/tests/test_event_extensions.py +++ b/tests/test_v1_compat/test_event_extensions.py @@ -16,7 +16,7 @@ import pytest -from cloudevents.http import CloudEvent, from_http, to_binary, to_structured +from cloudevents.v1.http import CloudEvent, from_http, to_binary, to_structured test_data = json.dumps({"data-key": "val"}) test_attributes = { diff --git a/cloudevents/tests/test_event_from_request_converter.py b/tests/test_v1_compat/test_event_from_request_converter.py similarity index 93% rename from cloudevents/tests/test_event_from_request_converter.py rename to tests/test_v1_compat/test_event_from_request_converter.py index 362b1cae..f2c2385d 100644 --- a/cloudevents/tests/test_event_from_request_converter.py +++ b/tests/test_v1_compat/test_event_from_request_converter.py @@ -16,10 +16,10 @@ import pytest -from cloudevents.sdk import marshaller -from cloudevents.sdk.converters import binary, structured -from cloudevents.sdk.event import v1, v03 -from cloudevents.tests import data +from cloudevents.v1.sdk import marshaller +from cloudevents.v1.sdk.converters import binary, structured +from cloudevents.v1.sdk.event import v03, v1 +from test_v1_compat import data @pytest.mark.parametrize("event_class", [v03.Event, v1.Event]) diff --git a/cloudevents/tests/test_event_pipeline.py b/tests/test_v1_compat/test_event_pipeline.py similarity index 94% rename from cloudevents/tests/test_event_pipeline.py rename to tests/test_v1_compat/test_event_pipeline.py index dae3dc2d..be972b70 100644 --- a/cloudevents/tests/test_event_pipeline.py +++ b/tests/test_v1_compat/test_event_pipeline.py @@ -16,10 +16,10 @@ import pytest -from cloudevents.sdk import converters, marshaller -from cloudevents.sdk.converters import structured -from cloudevents.sdk.event import v1, v03 -from cloudevents.tests import data +from cloudevents.v1.sdk import converters, marshaller +from cloudevents.v1.sdk.converters import structured +from cloudevents.v1.sdk.event import v03, v1 +from test_v1_compat import data @pytest.mark.parametrize("event_class", [v03.Event, v1.Event]) diff --git a/cloudevents/tests/test_event_to_request_converter.py b/tests/test_v1_compat/test_event_to_request_converter.py similarity index 93% rename from cloudevents/tests/test_event_to_request_converter.py rename to tests/test_v1_compat/test_event_to_request_converter.py index fd25be5a..598b76da 100644 --- a/cloudevents/tests/test_event_to_request_converter.py +++ b/tests/test_v1_compat/test_event_to_request_converter.py @@ -16,9 +16,9 @@ import pytest -from cloudevents.sdk import converters, marshaller -from cloudevents.sdk.event import v1, v03 -from cloudevents.tests import data +from cloudevents.v1.sdk import converters, marshaller +from cloudevents.v1.sdk.event import v03, v1 +from test_v1_compat import data @pytest.mark.parametrize("event_class", [v03.Event, v1.Event]) diff --git a/cloudevents/tests/test_http_cloudevent.py b/tests/test_v1_compat/test_http_cloudevent.py similarity index 97% rename from cloudevents/tests/test_http_cloudevent.py rename to tests/test_v1_compat/test_http_cloudevent.py index 6ad1537f..7efbd04d 100644 --- a/cloudevents/tests/test_http_cloudevent.py +++ b/tests/test_v1_compat/test_http_cloudevent.py @@ -14,9 +14,9 @@ import pytest -import cloudevents.exceptions as cloud_exceptions -from cloudevents.conversion import _json_or_string -from cloudevents.http import CloudEvent +import cloudevents.v1.exceptions as cloud_exceptions +from cloudevents.v1.conversion import _json_or_string +from cloudevents.v1.http import CloudEvent @pytest.fixture(params=["0.3", "1.0"]) diff --git a/cloudevents/tests/test_http_conversions.py b/tests/test_v1_compat/test_http_conversions.py similarity index 96% rename from cloudevents/tests/test_http_conversions.py rename to tests/test_v1_compat/test_http_conversions.py index 3b9c6717..d3f1f7c9 100644 --- a/cloudevents/tests/test_http_conversions.py +++ b/tests/test_v1_compat/test_http_conversions.py @@ -18,9 +18,9 @@ import pytest -from cloudevents.conversion import to_dict, to_json -from cloudevents.http import CloudEvent, from_dict, from_json -from cloudevents.sdk.event.attribute import SpecVersion +from cloudevents.v1.conversion import to_dict, to_json +from cloudevents.v1.http import CloudEvent, from_dict, from_json +from cloudevents.v1.sdk.event.attribute import SpecVersion test_data = json.dumps({"data-key": "val"}) test_attributes = { diff --git a/cloudevents/tests/test_http_events.py b/tests/test_v1_compat/test_http_events.py similarity index 97% rename from cloudevents/tests/test_http_events.py rename to tests/test_v1_compat/test_http_events.py index 3d4c8d52..ed6acb17 100644 --- a/cloudevents/tests/test_http_events.py +++ b/tests/test_v1_compat/test_http_events.py @@ -21,13 +21,13 @@ import pytest from sanic import Sanic, response -import cloudevents.exceptions as cloud_exceptions -from cloudevents.http import CloudEvent, from_http, to_binary, to_structured -from cloudevents.http.event_type import is_binary as deprecated_is_binary -from cloudevents.http.event_type import is_structured as deprecated_is_structured -from cloudevents.sdk import converters -from cloudevents.sdk.converters.binary import is_binary -from cloudevents.sdk.converters.structured import is_structured +import cloudevents.v1.exceptions as cloud_exceptions +from cloudevents.v1.http import CloudEvent, from_http, to_binary, to_structured +from cloudevents.v1.http.event_type import is_binary as deprecated_is_binary +from cloudevents.v1.http.event_type import is_structured as deprecated_is_structured +from cloudevents.v1.sdk import converters +from cloudevents.v1.sdk.converters.binary import is_binary +from cloudevents.v1.sdk.converters.structured import is_structured invalid_test_headers = [ { diff --git a/cloudevents/tests/test_kafka_conversions.py b/tests/test_v1_compat/test_kafka_conversions.py similarity index 97% rename from cloudevents/tests/test_kafka_conversions.py rename to tests/test_v1_compat/test_kafka_conversions.py index 584a05e4..da9f14d0 100644 --- a/cloudevents/tests/test_kafka_conversions.py +++ b/tests/test_v1_compat/test_kafka_conversions.py @@ -18,18 +18,17 @@ import pytest -from cloudevents import exceptions as cloud_exceptions -from cloudevents.abstract.event import AnyCloudEvent -from cloudevents.http import CloudEvent -from cloudevents.kafka.conversion import ( +from cloudevents.v1 import exceptions as cloud_exceptions +from cloudevents.v1.http import CloudEvent +from cloudevents.v1.kafka.conversion import ( KafkaMessage, from_binary, from_structured, to_binary, to_structured, ) -from cloudevents.kafka.exceptions import KeyMapperError -from cloudevents.sdk import types +from cloudevents.v1.kafka.exceptions import KeyMapperError +from cloudevents.v1.sdk import types def simple_serialize(data: dict) -> bytes: @@ -37,9 +36,7 @@ def simple_serialize(data: dict) -> bytes: def simple_deserialize(data: bytes) -> dict: - value = json.loads(data.decode()) - assert isinstance(value, dict) - return value + return json.loads(data.decode()) def failing_func(*args): @@ -50,7 +47,7 @@ class KafkaConversionTestBase: expected_data = {"name": "test", "amount": 1} expected_custom_mapped_key = "custom-key" - def custom_key_mapper(self, _: AnyCloudEvent) -> str: + def custom_key_mapper(self, _) -> str: return self.expected_custom_mapped_key @pytest.fixture diff --git a/cloudevents/tests/test_marshaller.py b/tests/test_v1_compat/test_marshaller.py similarity index 90% rename from cloudevents/tests/test_marshaller.py rename to tests/test_v1_compat/test_marshaller.py index 6561418b..0d429760 100644 --- a/cloudevents/tests/test_marshaller.py +++ b/tests/test_v1_compat/test_marshaller.py @@ -16,11 +16,11 @@ import pytest -import cloudevents.exceptions as cloud_exceptions -from cloudevents.http import CloudEvent, from_http, to_binary, to_structured -from cloudevents.sdk import exceptions, marshaller -from cloudevents.sdk.converters import binary, structured -from cloudevents.sdk.event import v1 +import cloudevents.v1.exceptions as cloud_exceptions +from cloudevents.v1.http import CloudEvent, from_http, to_binary, to_structured +from cloudevents.v1.sdk import exceptions, marshaller +from cloudevents.v1.sdk.converters import binary, structured +from cloudevents.v1.sdk.event import v1 @pytest.fixture @@ -50,7 +50,10 @@ def test_from_request_wrong_unmarshaller(): with pytest.raises(exceptions.InvalidDataUnmarshaller): m = marshaller.NewDefaultHTTPMarshaller() _ = m.FromRequest( - event=v1.Event(), headers={}, body="", data_unmarshaller=object() # type: ignore[arg-type] # intentionally wrong type # noqa: E501 + event=v1.Event(), + headers={}, + body="", + data_unmarshaller=object(), # type: ignore[arg-type] # intentionally wrong type # noqa: E501 ) diff --git a/cloudevents/tests/test_options.py b/tests/test_v1_compat/test_options.py similarity index 95% rename from cloudevents/tests/test_options.py rename to tests/test_v1_compat/test_options.py index aba812b9..dc54f255 100644 --- a/cloudevents/tests/test_options.py +++ b/tests/test_v1_compat/test_options.py @@ -14,7 +14,7 @@ import pytest -from cloudevents.sdk.event.opt import Option +from cloudevents.v1.sdk.event.opt import Option def test_set_raise_error(): diff --git a/cloudevents/tests/test_pydantic_cloudevent.py b/tests/test_v1_compat/test_pydantic_cloudevent.py similarity index 97% rename from cloudevents/tests/test_pydantic_cloudevent.py rename to tests/test_v1_compat/test_pydantic_cloudevent.py index 87ac5507..c84c8ae1 100644 --- a/cloudevents/tests/test_pydantic_cloudevent.py +++ b/tests/test_v1_compat/test_pydantic_cloudevent.py @@ -18,11 +18,11 @@ from pydantic import ValidationError as PydanticV2ValidationError from pydantic.v1 import ValidationError as PydanticV1ValidationError -from cloudevents.conversion import _json_or_string -from cloudevents.exceptions import IncompatibleArgumentsError -from cloudevents.pydantic.v1.event import CloudEvent as PydanticV1CloudEvent -from cloudevents.pydantic.v2.event import CloudEvent as PydanticV2CloudEvent -from cloudevents.sdk.event.attribute import SpecVersion +from cloudevents.v1.conversion import _json_or_string +from cloudevents.v1.exceptions import IncompatibleArgumentsError +from cloudevents.v1.pydantic.v1.event import CloudEvent as PydanticV1CloudEvent +from cloudevents.v1.pydantic.v2.event import CloudEvent as PydanticV2CloudEvent +from cloudevents.v1.sdk.event.attribute import SpecVersion _DUMMY_SOURCE = "dummy:source" _DUMMY_TYPE = "tests.cloudevents.override" diff --git a/cloudevents/tests/test_pydantic_conversions.py b/tests/test_v1_compat/test_pydantic_conversions.py similarity index 90% rename from cloudevents/tests/test_pydantic_conversions.py rename to tests/test_v1_compat/test_pydantic_conversions.py index 801b76bd..80fe1bee 100644 --- a/cloudevents/tests/test_pydantic_conversions.py +++ b/tests/test_v1_compat/test_pydantic_conversions.py @@ -20,14 +20,14 @@ from pydantic import ValidationError as PydanticV2ValidationError from pydantic.v1 import ValidationError as PydanticV1ValidationError -from cloudevents.conversion import to_json -from cloudevents.pydantic.v1.conversion import from_dict as pydantic_v1_from_dict -from cloudevents.pydantic.v1.conversion import from_json as pydantic_v1_from_json -from cloudevents.pydantic.v1.event import CloudEvent as PydanticV1CloudEvent -from cloudevents.pydantic.v2.conversion import from_dict as pydantic_v2_from_dict -from cloudevents.pydantic.v2.conversion import from_json as pydantic_v2_from_json -from cloudevents.pydantic.v2.event import CloudEvent as PydanticV2CloudEvent -from cloudevents.sdk.event.attribute import SpecVersion +from cloudevents.v1.conversion import to_json +from cloudevents.v1.pydantic.v1.conversion import from_dict as pydantic_v1_from_dict +from cloudevents.v1.pydantic.v1.conversion import from_json as pydantic_v1_from_json +from cloudevents.v1.pydantic.v1.event import CloudEvent as PydanticV1CloudEvent +from cloudevents.v1.pydantic.v2.conversion import from_dict as pydantic_v2_from_dict +from cloudevents.v1.pydantic.v2.conversion import from_json as pydantic_v2_from_json +from cloudevents.v1.pydantic.v2.event import CloudEvent as PydanticV2CloudEvent +from cloudevents.v1.sdk.event.attribute import SpecVersion test_data = json.dumps({"data-key": "val"}) test_attributes = { diff --git a/cloudevents/tests/test_pydantic_events.py b/tests/test_v1_compat/test_pydantic_events.py similarity index 78% rename from cloudevents/tests/test_pydantic_events.py rename to tests/test_v1_compat/test_pydantic_events.py index 30ad1fe3..115c92a2 100644 --- a/cloudevents/tests/test_pydantic_events.py +++ b/tests/test_v1_compat/test_pydantic_events.py @@ -11,7 +11,6 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. -from __future__ import annotations import bz2 import io @@ -23,18 +22,15 @@ from pydantic.v1 import ValidationError as PydanticV1ValidationError from sanic import Sanic, response -import cloudevents.exceptions as cloud_exceptions -from cloudevents.conversion import to_binary, to_structured -from cloudevents.pydantic.v1.conversion import from_http as pydantic_v1_from_http -from cloudevents.pydantic.v1.event import CloudEvent as PydanticV1CloudEvent -from cloudevents.pydantic.v2.conversion import from_http as pydantic_v2_from_http -from cloudevents.pydantic.v2.event import CloudEvent as PydanticV2CloudEvent -from cloudevents.sdk import converters, types -from cloudevents.sdk.converters.binary import is_binary -from cloudevents.sdk.converters.structured import is_structured - -if typing.TYPE_CHECKING: - from typing_extensions import TypeAlias +import cloudevents.v1.exceptions as cloud_exceptions +from cloudevents.v1.conversion import to_binary, to_structured +from cloudevents.v1.pydantic.v1.conversion import from_http as pydantic_v1_from_http +from cloudevents.v1.pydantic.v1.event import CloudEvent as PydanticV1CloudEvent +from cloudevents.v1.pydantic.v2.conversion import from_http as pydantic_v2_from_http +from cloudevents.v1.pydantic.v2.event import CloudEvent as PydanticV2CloudEvent +from cloudevents.v1.sdk import converters +from cloudevents.v1.sdk.converters.binary import is_binary +from cloudevents.v1.sdk.converters.structured import is_structured invalid_test_headers = [ { @@ -74,30 +70,7 @@ app = Sanic("test_pydantic_http_events") - -AnyPydanticCloudEvent: TypeAlias = typing.Union[ - PydanticV1CloudEvent, PydanticV2CloudEvent -] - - -class FromHttpFn(typing.Protocol): - def __call__( - self, - headers: typing.Dict[str, str], - data: typing.Optional[typing.AnyStr], - data_unmarshaller: typing.Optional[types.UnmarshallerType] = None, - ) -> AnyPydanticCloudEvent: - pass - - -class PydanticImplementation(typing.TypedDict): - event: typing.Type[AnyPydanticCloudEvent] - validation_error: typing.Type[Exception] - from_http: FromHttpFn - pydantic_version: typing.Literal["v1", "v2"] - - -_pydantic_implementation: typing.Mapping[str, PydanticImplementation] = { +_pydantic_implementation = { "v1": { "event": PydanticV1CloudEvent, "validation_error": PydanticV1ValidationError, @@ -114,9 +87,7 @@ class PydanticImplementation(typing.TypedDict): @pytest.fixture(params=["v1", "v2"]) -def cloudevents_implementation( - request: pytest.FixtureRequest, -) -> PydanticImplementation: +def cloudevents_implementation(request): return _pydantic_implementation[request.param] @@ -137,9 +108,7 @@ async def echo(request, pydantic_version): @pytest.mark.parametrize("body", invalid_cloudevent_request_body) -def test_missing_required_fields_structured( - body: dict, cloudevents_implementation: PydanticImplementation -) -> None: +def test_missing_required_fields_structured(body, cloudevents_implementation): with pytest.raises(cloud_exceptions.MissingRequiredFields): _ = cloudevents_implementation["from_http"]( {"Content-Type": "application/cloudevents+json"}, json.dumps(body) @@ -147,26 +116,20 @@ def test_missing_required_fields_structured( @pytest.mark.parametrize("headers", invalid_test_headers) -def test_missing_required_fields_binary( - headers: dict, cloudevents_implementation: PydanticImplementation -) -> None: +def test_missing_required_fields_binary(headers, cloudevents_implementation): with pytest.raises(cloud_exceptions.MissingRequiredFields): _ = cloudevents_implementation["from_http"](headers, json.dumps(test_data)) @pytest.mark.parametrize("headers", invalid_test_headers) -def test_missing_required_fields_empty_data_binary( - headers: dict, cloudevents_implementation: PydanticImplementation -) -> None: +def test_missing_required_fields_empty_data_binary(headers, cloudevents_implementation): # Test for issue #115 with pytest.raises(cloud_exceptions.MissingRequiredFields): _ = cloudevents_implementation["from_http"](headers, None) @pytest.mark.parametrize("specversion", ["1.0", "0.3"]) -def test_emit_binary_event( - specversion: str, cloudevents_implementation: PydanticImplementation -) -> None: +def test_emit_binary_event(specversion, cloudevents_implementation): headers = { "ce-id": "my-id", "ce-source": "", @@ -196,9 +159,7 @@ def test_emit_binary_event( @pytest.mark.parametrize("specversion", ["1.0", "0.3"]) -def test_emit_structured_event( - specversion: str, cloudevents_implementation: PydanticImplementation -) -> None: +def test_emit_structured_event(specversion, cloudevents_implementation): headers = {"Content-Type": "application/cloudevents+json"} body = { "id": "my-id", @@ -227,11 +188,7 @@ def test_emit_structured_event( "converter", [converters.TypeBinary, converters.TypeStructured] ) @pytest.mark.parametrize("specversion", ["1.0", "0.3"]) -def test_roundtrip_non_json_event( - converter: str, - specversion: str, - cloudevents_implementation: PydanticImplementation, -) -> None: +def test_roundtrip_non_json_event(converter, specversion, cloudevents_implementation): input_data = io.BytesIO() for _ in range(100): for j in range(20): @@ -260,9 +217,7 @@ def test_roundtrip_non_json_event( @pytest.mark.parametrize("specversion", ["1.0", "0.3"]) -def test_missing_ce_prefix_binary_event( - specversion: str, cloudevents_implementation: PydanticImplementation -) -> None: +def test_missing_ce_prefix_binary_event(specversion, cloudevents_implementation): prefixed_headers = {} headers = { "ce-id": "my-id", @@ -285,11 +240,9 @@ def test_missing_ce_prefix_binary_event( @pytest.mark.parametrize("specversion", ["1.0", "0.3"]) -def test_valid_binary_events( - specversion: str, cloudevents_implementation: PydanticImplementation -) -> None: +def test_valid_binary_events(specversion, cloudevents_implementation): # Test creating multiple cloud events - events_queue: list[AnyPydanticCloudEvent] = [] + events_queue = [] headers = {} num_cloudevents = 30 for i in range(num_cloudevents): @@ -305,7 +258,7 @@ def test_valid_binary_events( ) for i, event in enumerate(events_queue): - assert isinstance(event.data, dict) + data = event.data assert event["id"] == f"id{i}" assert event["source"] == f"source{i}.com.test" assert event["specversion"] == specversion @@ -313,9 +266,7 @@ def test_valid_binary_events( @pytest.mark.parametrize("specversion", ["1.0", "0.3"]) -def test_structured_to_request( - specversion: str, cloudevents_implementation: PydanticImplementation -) -> None: +def test_structured_to_request(specversion, cloudevents_implementation): attributes = { "specversion": specversion, "type": "word.found.name", @@ -332,13 +283,11 @@ def test_structured_to_request( assert headers["content-type"] == "application/cloudevents+json" for key in attributes: assert body[key] == attributes[key] - assert body["data"] == data, f"|{body_bytes!r}|| {body}" + assert body["data"] == data, f"|{body_bytes}|| {body}" @pytest.mark.parametrize("specversion", ["1.0", "0.3"]) -def test_attributes_view_accessor( - specversion: str, cloudevents_implementation: PydanticImplementation -) -> None: +def test_attributes_view_accessor(specversion: str, cloudevents_implementation): attributes: dict[str, typing.Any] = { "specversion": specversion, "type": "word.found.name", @@ -347,7 +296,9 @@ def test_attributes_view_accessor( } data = {"message": "Hello World!"} - event = cloudevents_implementation["event"](attributes, data) + event: cloudevents_implementation["event"] = cloudevents_implementation["event"]( + attributes, data + ) event_attributes: typing.Mapping[str, typing.Any] = event.get_attributes() assert event_attributes["specversion"] == attributes["specversion"] assert event_attributes["type"] == attributes["type"] @@ -357,9 +308,7 @@ def test_attributes_view_accessor( @pytest.mark.parametrize("specversion", ["1.0", "0.3"]) -def test_binary_to_request( - specversion: str, cloudevents_implementation: PydanticImplementation -) -> None: +def test_binary_to_request(specversion, cloudevents_implementation): attributes = { "specversion": specversion, "type": "word.found.name", @@ -378,9 +327,7 @@ def test_binary_to_request( @pytest.mark.parametrize("specversion", ["1.0", "0.3"]) -def test_empty_data_structured_event( - specversion: str, cloudevents_implementation: PydanticImplementation -) -> None: +def test_empty_data_structured_event(specversion, cloudevents_implementation): # Testing if cloudevent breaks when no structured data field present attributes = { "specversion": specversion, @@ -405,9 +352,7 @@ def test_empty_data_structured_event( @pytest.mark.parametrize("specversion", ["1.0", "0.3"]) -def test_empty_data_binary_event( - specversion: str, cloudevents_implementation: PydanticImplementation -) -> None: +def test_empty_data_binary_event(specversion, cloudevents_implementation): # Testing if cloudevent breaks when no structured data field present headers = { "Content-Type": "application/octet-stream", @@ -427,14 +372,12 @@ def test_empty_data_binary_event( @pytest.mark.parametrize("specversion", ["1.0", "0.3"]) -def test_valid_structured_events( - specversion: str, cloudevents_implementation: PydanticImplementation -) -> None: +def test_valid_structured_events(specversion, cloudevents_implementation): # Test creating multiple cloud events - events_queue: list[AnyPydanticCloudEvent] = [] + events_queue = [] num_cloudevents = 30 for i in range(num_cloudevents): - raw_event = { + event = { "id": f"id{i}", "source": f"source{i}.com.test", "type": "cloudevent.test.type", @@ -444,12 +387,11 @@ def test_valid_structured_events( events_queue.append( cloudevents_implementation["from_http"]( {"content-type": "application/cloudevents+json"}, - json.dumps(raw_event), + json.dumps(event), ) ) for i, event in enumerate(events_queue): - assert isinstance(event.data, dict) assert event["id"] == f"id{i}" assert event["source"] == f"source{i}.com.test" assert event["specversion"] == specversion @@ -457,9 +399,7 @@ def test_valid_structured_events( @pytest.mark.parametrize("specversion", ["1.0", "0.3"]) -def test_structured_no_content_type( - specversion: str, cloudevents_implementation: PydanticImplementation -) -> None: +def test_structured_no_content_type(specversion, cloudevents_implementation): # Test creating multiple cloud events data = { "id": "id", @@ -470,7 +410,6 @@ def test_structured_no_content_type( } event = cloudevents_implementation["from_http"]({}, json.dumps(data)) - assert isinstance(event.data, dict) assert event["id"] == "id" assert event["source"] == "source.com.test" assert event["specversion"] == specversion @@ -498,9 +437,7 @@ def test_is_binary(): @pytest.mark.parametrize("specversion", ["1.0", "0.3"]) -def test_cloudevent_repr( - specversion: str, cloudevents_implementation: PydanticImplementation -) -> None: +def test_cloudevent_repr(specversion, cloudevents_implementation): headers = { "Content-Type": "application/octet-stream", "ce-specversion": specversion, @@ -517,9 +454,7 @@ def test_cloudevent_repr( @pytest.mark.parametrize("specversion", ["1.0", "0.3"]) -def test_none_data_cloudevent( - specversion: str, cloudevents_implementation: PydanticImplementation -) -> None: +def test_none_data_cloudevent(specversion, cloudevents_implementation): event = cloudevents_implementation["event"]( { "source": "", @@ -531,7 +466,7 @@ def test_none_data_cloudevent( to_structured(event) -def test_wrong_specversion(cloudevents_implementation: PydanticImplementation) -> None: +def test_wrong_specversion(cloudevents_implementation): headers = {"Content-Type": "application/cloudevents+json"} data = json.dumps( { @@ -546,19 +481,15 @@ def test_wrong_specversion(cloudevents_implementation: PydanticImplementation) - assert "Found invalid specversion 0.2" in str(e.value) -def test_invalid_data_format_structured_from_http( - cloudevents_implementation: PydanticImplementation, -) -> None: +def test_invalid_data_format_structured_from_http(cloudevents_implementation): headers = {"Content-Type": "application/cloudevents+json"} data = 20 with pytest.raises(cloud_exceptions.InvalidStructuredJSON) as e: - cloudevents_implementation["from_http"](headers, data) # type: ignore[type-var] # intentionally wrong type # noqa: E501 + cloudevents_implementation["from_http"](headers, data) assert "Expected json of type (str, bytes, bytearray)" in str(e.value) -def test_wrong_specversion_to_request( - cloudevents_implementation: PydanticImplementation, -) -> None: +def test_wrong_specversion_to_request(cloudevents_implementation): event = cloudevents_implementation["event"]({"source": "s", "type": "t"}, None) with pytest.raises(cloud_exceptions.InvalidRequiredFields) as e: event["specversion"] = "0.2" @@ -582,9 +513,7 @@ def test_is_structured(): assert not is_structured(headers) -def test_empty_json_structured( - cloudevents_implementation: PydanticImplementation, -) -> None: +def test_empty_json_structured(cloudevents_implementation): headers = {"Content-Type": "application/cloudevents+json"} data = "" with pytest.raises(cloud_exceptions.MissingRequiredFields) as e: @@ -592,9 +521,7 @@ def test_empty_json_structured( assert "Failed to read specversion from both headers and data" in str(e.value) -def test_uppercase_headers_with_none_data_binary( - cloudevents_implementation: PydanticImplementation, -) -> None: +def test_uppercase_headers_with_none_data_binary(cloudevents_implementation): headers = { "Ce-Id": "my-id", "Ce-Source": "", @@ -611,7 +538,7 @@ def test_uppercase_headers_with_none_data_binary( assert new_data is None -def test_generic_exception(cloudevents_implementation: PydanticImplementation) -> None: +def test_generic_exception(cloudevents_implementation): headers = {"Content-Type": "application/cloudevents+json"} data = json.dumps( { @@ -627,7 +554,7 @@ def test_generic_exception(cloudevents_implementation: PydanticImplementation) - e.errisinstance(cloud_exceptions.MissingRequiredFields) with pytest.raises(cloud_exceptions.GenericException) as e: - cloudevents_implementation["from_http"]({}, 123) # type: ignore[type-var] # intentionally wrong type # noqa: E501 + cloudevents_implementation["from_http"]({}, 123) e.errisinstance(cloud_exceptions.InvalidStructuredJSON) with pytest.raises(cloud_exceptions.GenericException) as e: @@ -642,9 +569,7 @@ def test_generic_exception(cloudevents_implementation: PydanticImplementation) - e.errisinstance(cloud_exceptions.DataMarshallerError) -def test_non_dict_data_no_headers_bug( - cloudevents_implementation: PydanticImplementation, -) -> None: +def test_non_dict_data_no_headers_bug(cloudevents_implementation): # Test for issue #116 headers = {"Content-Type": "application/cloudevents+json"} data = "123" diff --git a/cloudevents/tests/test_v03_event.py b/tests/test_v1_compat/test_v03_event.py similarity index 97% rename from cloudevents/tests/test_v03_event.py rename to tests/test_v1_compat/test_v03_event.py index a4755318..9647c742 100644 --- a/cloudevents/tests/test_v03_event.py +++ b/tests/test_v1_compat/test_v03_event.py @@ -12,7 +12,7 @@ # License for the specific language governing permissions and limitations # under the License. -from cloudevents.sdk.event import v03 +from cloudevents.v1.sdk.event import v03 def test_v03_time_property(): diff --git a/cloudevents/tests/test_v1_event.py b/tests/test_v1_compat/test_v1_event.py similarity index 97% rename from cloudevents/tests/test_v1_event.py rename to tests/test_v1_compat/test_v1_event.py index de900b0a..4cc9eade 100644 --- a/cloudevents/tests/test_v1_event.py +++ b/tests/test_v1_compat/test_v1_event.py @@ -12,7 +12,7 @@ # License for the specific language governing permissions and limitations # under the License. -from cloudevents.sdk.event import v1 +from cloudevents.v1.sdk.event import v1 def test_v1_time_property(): diff --git a/cloudevents/tests/test_with_sanic.py b/tests/test_v1_compat/test_with_sanic.py similarity index 93% rename from cloudevents/tests/test_with_sanic.py rename to tests/test_v1_compat/test_with_sanic.py index 026f55b7..5c516e63 100644 --- a/cloudevents/tests/test_with_sanic.py +++ b/tests/test_v1_compat/test_with_sanic.py @@ -14,9 +14,9 @@ from sanic import Sanic, response -from cloudevents.sdk import converters, marshaller -from cloudevents.sdk.event import v1 -from cloudevents.tests import data as test_data +from cloudevents.v1.sdk import converters, marshaller +from cloudevents.v1.sdk.event import v1 +from test_v1_compat import data as test_data m = marshaller.NewDefaultHTTPMarshaller() app = Sanic("test_with_sanic") diff --git a/tox.ini b/tox.ini deleted file mode 100644 index d5f1d984..00000000 --- a/tox.ini +++ /dev/null @@ -1,50 +0,0 @@ -[tox] -envlist = py{39,310,311,312,313},lint,mypy,mypy-samples-{image,json} -skipsdist = True - -[testenv] -usedevelop = True -deps = - -r{toxinidir}/requirements/test.txt - -r{toxinidir}/requirements/publish.txt -setenv = - PYTESTARGS = -v -s --tb=long --cov=cloudevents --cov-report term-missing --cov-fail-under=95 -commands = pytest {env:PYTESTARGS} {posargs} - -[testenv:reformat] -basepython = python3.12 -deps = - black - isort -commands = - black . - isort cloudevents samples - -[testenv:lint] -basepython = python3.12 -deps = - black - isort - flake8 -commands = - black --check . - isort -c cloudevents samples - flake8 cloudevents samples --ignore W503,E731 --extend-ignore E203 --max-line-length 88 - -[testenv:mypy] -basepython = python3.12 -deps = - -r{toxinidir}/requirements/mypy.txt - # mypy needs test dependencies to check test modules - -r{toxinidir}/requirements/test.txt -commands = mypy cloudevents - -[testenv:mypy-samples-{image,json}] -basepython = python3.12 -setenv = - mypy-samples-image: SAMPLE_DIR={toxinidir}/samples/http-image-cloudevents - mypy-samples-json: SAMPLE_DIR={toxinidir}/samples/http-json-cloudevents -deps = - -r{toxinidir}/requirements/mypy.txt - -r{env:SAMPLE_DIR}/requirements.txt -commands = mypy {env:SAMPLE_DIR} diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..c31e5811 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1329 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, +] + +[[package]] +name = "cloudevents" +source = { editable = "." } +dependencies = [ + { name = "deprecation" }, + { name = "python-dateutil" }, +] + +[package.dev-dependencies] +dev = [ + { name = "flake8" }, + { name = "flake8-print" }, + { name = "isort" }, + { name = "mypy" }, + { name = "pep8-naming" }, + { name = "pre-commit" }, + { name = "pydantic" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "sanic" }, + { name = "sanic-testing" }, + { name = "types-python-dateutil" }, +] + +[package.metadata] +requires-dist = [ + { name = "deprecation", specifier = ">=2.0,<3.0" }, + { name = "python-dateutil", specifier = ">=2.8.2" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "flake8", specifier = ">=7.3.0" }, + { name = "flake8-print", specifier = ">=5.0.0" }, + { name = "isort", specifier = ">=8.0.1" }, + { name = "mypy", specifier = ">=1.19.1" }, + { name = "pep8-naming", specifier = ">=0.15.1" }, + { name = "pre-commit", specifier = ">=4.5.1" }, + { name = "pydantic", specifier = ">=2.12.5" }, + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, + { name = "ruff", specifier = ">=0.15.7" }, + { name = "sanic", specifier = ">=25.12.0" }, + { name = "sanic-testing", specifier = ">=24.6.0" }, + { name = "types-python-dateutil", specifier = ">=2.9.0.20260305" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/45/2c665ca77ec32ad67e25c77daf1cee28ee4558f3bc571cdbaf88a00b9f23/coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936", size = 820905, upload-time = "2025-12-08T13:14:38.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/08/bdd7ccca14096f7eb01412b87ac11e5d16e4cb54b6e328afc9dee8bdaec1/coverage-7.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:02d9fb9eccd48f6843c98a37bd6817462f130b86da8660461e8f5e54d4c06070", size = 217979, upload-time = "2025-12-08T13:12:14.505Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/d1302e3416298a28b5663ae1117546a745d9d19fde7e28402b2c5c3e2109/coverage-7.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:367449cf07d33dc216c083f2036bb7d976c6e4903ab31be400ad74ad9f85ce98", size = 218496, upload-time = "2025-12-08T13:12:16.237Z" }, + { url = "https://files.pythonhosted.org/packages/07/26/d36c354c8b2a320819afcea6bffe72839efd004b98d1d166b90801d49d57/coverage-7.13.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cdb3c9f8fef0a954c632f64328a3935988d33a6604ce4bf67ec3e39670f12ae5", size = 245237, upload-time = "2025-12-08T13:12:17.858Z" }, + { url = "https://files.pythonhosted.org/packages/91/52/be5e85631e0eec547873d8b08dd67a5f6b111ecfe89a86e40b89b0c1c61c/coverage-7.13.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d10fd186aac2316f9bbb46ef91977f9d394ded67050ad6d84d94ed6ea2e8e54e", size = 247061, upload-time = "2025-12-08T13:12:19.132Z" }, + { url = "https://files.pythonhosted.org/packages/0f/45/a5e8fa0caf05fbd8fa0402470377bff09cc1f026d21c05c71e01295e55ab/coverage-7.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f88ae3e69df2ab62fb0bc5219a597cb890ba5c438190ffa87490b315190bb33", size = 248928, upload-time = "2025-12-08T13:12:20.702Z" }, + { url = "https://files.pythonhosted.org/packages/f5/42/ffb5069b6fd1b95fae482e02f3fecf380d437dd5a39bae09f16d2e2e7e01/coverage-7.13.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4be718e51e86f553bcf515305a158a1cd180d23b72f07ae76d6017c3cc5d791", size = 245931, upload-time = "2025-12-08T13:12:22.243Z" }, + { url = "https://files.pythonhosted.org/packages/95/6e/73e809b882c2858f13e55c0c36e94e09ce07e6165d5644588f9517efe333/coverage-7.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a00d3a393207ae12f7c49bb1c113190883b500f48979abb118d8b72b8c95c032", size = 246968, upload-time = "2025-12-08T13:12:23.52Z" }, + { url = "https://files.pythonhosted.org/packages/87/08/64ebd9e64b6adb8b4a4662133d706fbaccecab972e0b3ccc23f64e2678ad/coverage-7.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a7b1cd820e1b6116f92c6128f1188e7afe421c7e1b35fa9836b11444e53ebd9", size = 244972, upload-time = "2025-12-08T13:12:24.781Z" }, + { url = "https://files.pythonhosted.org/packages/12/97/f4d27c6fe0cb375a5eced4aabcaef22de74766fb80a3d5d2015139e54b22/coverage-7.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:37eee4e552a65866f15dedd917d5e5f3d59805994260720821e2c1b51ac3248f", size = 245241, upload-time = "2025-12-08T13:12:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/0c/94/42f8ae7f633bf4c118bf1038d80472f9dade88961a466f290b81250f7ab7/coverage-7.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62d7c4f13102148c78d7353c6052af6d899a7f6df66a32bddcc0c0eb7c5326f8", size = 245847, upload-time = "2025-12-08T13:12:29.337Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2f/6369ca22b6b6d933f4f4d27765d313d8914cc4cce84f82a16436b1a233db/coverage-7.13.0-cp310-cp310-win32.whl", hash = "sha256:24e4e56304fdb56f96f80eabf840eab043b3afea9348b88be680ec5986780a0f", size = 220573, upload-time = "2025-12-08T13:12:30.905Z" }, + { url = "https://files.pythonhosted.org/packages/f1/dc/a6a741e519acceaeccc70a7f4cfe5d030efc4b222595f0677e101af6f1f3/coverage-7.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:74c136e4093627cf04b26a35dab8cbfc9b37c647f0502fc313376e11726ba303", size = 221509, upload-time = "2025-12-08T13:12:32.09Z" }, + { url = "https://files.pythonhosted.org/packages/f1/dc/888bf90d8b1c3d0b4020a40e52b9f80957d75785931ec66c7dfaccc11c7d/coverage-7.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0dfa3855031070058add1a59fdfda0192fd3e8f97e7c81de0596c145dea51820", size = 218104, upload-time = "2025-12-08T13:12:33.333Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ea/069d51372ad9c380214e86717e40d1a743713a2af191cfba30a0911b0a4a/coverage-7.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fdb6f54f38e334db97f72fa0c701e66d8479af0bc3f9bfb5b90f1c30f54500f", size = 218606, upload-time = "2025-12-08T13:12:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/68/09/77b1c3a66c2aa91141b6c4471af98e5b1ed9b9e6d17255da5eb7992299e3/coverage-7.13.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7e442c013447d1d8d195be62852270b78b6e255b79b8675bad8479641e21fd96", size = 248999, upload-time = "2025-12-08T13:12:36.02Z" }, + { url = "https://files.pythonhosted.org/packages/0a/32/2e2f96e9d5691eaf1181d9040f850b8b7ce165ea10810fd8e2afa534cef7/coverage-7.13.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ed5630d946859de835a85e9a43b721123a8a44ec26e2830b296d478c7fd4259", size = 250925, upload-time = "2025-12-08T13:12:37.221Z" }, + { url = "https://files.pythonhosted.org/packages/7b/45/b88ddac1d7978859b9a39a8a50ab323186148f1d64bc068f86fc77706321/coverage-7.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f15a931a668e58087bc39d05d2b4bf4b14ff2875b49c994bbdb1c2217a8daeb", size = 253032, upload-time = "2025-12-08T13:12:38.763Z" }, + { url = "https://files.pythonhosted.org/packages/71/cb/e15513f94c69d4820a34b6bf3d2b1f9f8755fa6021be97c7065442d7d653/coverage-7.13.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30a3a201a127ea57f7e14ba43c93c9c4be8b7d17a26e03bb49e6966d019eede9", size = 249134, upload-time = "2025-12-08T13:12:40.382Z" }, + { url = "https://files.pythonhosted.org/packages/09/61/d960ff7dc9e902af3310ce632a875aaa7860f36d2bc8fc8b37ee7c1b82a5/coverage-7.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a485ff48fbd231efa32d58f479befce52dcb6bfb2a88bb7bf9a0b89b1bc8030", size = 250731, upload-time = "2025-12-08T13:12:41.992Z" }, + { url = "https://files.pythonhosted.org/packages/98/34/c7c72821794afc7c7c2da1db8f00c2c98353078aa7fb6b5ff36aac834b52/coverage-7.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:22486cdafba4f9e471c816a2a5745337742a617fef68e890d8baf9f3036d7833", size = 248795, upload-time = "2025-12-08T13:12:43.331Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5b/e0f07107987a43b2def9aa041c614ddb38064cbf294a71ef8c67d43a0cdd/coverage-7.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:263c3dbccc78e2e331e59e90115941b5f53e85cfcc6b3b2fbff1fd4e3d2c6ea8", size = 248514, upload-time = "2025-12-08T13:12:44.546Z" }, + { url = "https://files.pythonhosted.org/packages/71/c2/c949c5d3b5e9fc6dd79e1b73cdb86a59ef14f3709b1d72bf7668ae12e000/coverage-7.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5330fa0cc1f5c3c4c3bb8e101b742025933e7848989370a1d4c8c5e401ea753", size = 249424, upload-time = "2025-12-08T13:12:45.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/f1/bbc009abd6537cec0dffb2cc08c17a7f03de74c970e6302db4342a6e05af/coverage-7.13.0-cp311-cp311-win32.whl", hash = "sha256:0f4872f5d6c54419c94c25dd6ae1d015deeb337d06e448cd890a1e89a8ee7f3b", size = 220597, upload-time = "2025-12-08T13:12:47.378Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f6/d9977f2fb51c10fbaed0718ce3d0a8541185290b981f73b1d27276c12d91/coverage-7.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51a202e0f80f241ccb68e3e26e19ab5b3bf0f813314f2c967642f13ebcf1ddfe", size = 221536, upload-time = "2025-12-08T13:12:48.7Z" }, + { url = "https://files.pythonhosted.org/packages/be/ad/3fcf43fd96fb43e337a3073dea63ff148dcc5c41ba7a14d4c7d34efb2216/coverage-7.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:d2a9d7f1c11487b1c69367ab3ac2d81b9b3721f097aa409a3191c3e90f8f3dd7", size = 220206, upload-time = "2025-12-08T13:12:50.365Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f1/2619559f17f31ba00fc40908efd1fbf1d0a5536eb75dc8341e7d660a08de/coverage-7.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0b3d67d31383c4c68e19a88e28fc4c2e29517580f1b0ebec4a069d502ce1e0bf", size = 218274, upload-time = "2025-12-08T13:12:52.095Z" }, + { url = "https://files.pythonhosted.org/packages/2b/11/30d71ae5d6e949ff93b2a79a2c1b4822e00423116c5c6edfaeef37301396/coverage-7.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:581f086833d24a22c89ae0fe2142cfaa1c92c930adf637ddf122d55083fb5a0f", size = 218638, upload-time = "2025-12-08T13:12:53.418Z" }, + { url = "https://files.pythonhosted.org/packages/79/c2/fce80fc6ded8d77e53207489d6065d0fed75db8951457f9213776615e0f5/coverage-7.13.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a3a30f0e257df382f5f9534d4ce3d4cf06eafaf5192beb1a7bd066cb10e78fb", size = 250129, upload-time = "2025-12-08T13:12:54.744Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b6/51b5d1eb6fcbb9a1d5d6984e26cbe09018475c2922d554fd724dd0f056ee/coverage-7.13.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:583221913fbc8f53b88c42e8dbb8fca1d0f2e597cb190ce45916662b8b9d9621", size = 252885, upload-time = "2025-12-08T13:12:56.401Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/972a5affea41de798691ab15d023d3530f9f56a72e12e243f35031846ff7/coverage-7.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f5d9bd30756fff3e7216491a0d6d520c448d5124d3d8e8f56446d6412499e74", size = 253974, upload-time = "2025-12-08T13:12:57.718Z" }, + { url = "https://files.pythonhosted.org/packages/8a/56/116513aee860b2c7968aa3506b0f59b22a959261d1dbf3aea7b4450a7520/coverage-7.13.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a23e5a1f8b982d56fa64f8e442e037f6ce29322f1f9e6c2344cd9e9f4407ee57", size = 250538, upload-time = "2025-12-08T13:12:59.254Z" }, + { url = "https://files.pythonhosted.org/packages/d6/75/074476d64248fbadf16dfafbf93fdcede389ec821f74ca858d7c87d2a98c/coverage-7.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b01c22bc74a7fb44066aaf765224c0d933ddf1f5047d6cdfe4795504a4493f8", size = 251912, upload-time = "2025-12-08T13:13:00.604Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d2/aa4f8acd1f7c06024705c12609d8698c51b27e4d635d717cd1934c9668e2/coverage-7.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:898cce66d0836973f48dda4e3514d863d70142bdf6dfab932b9b6a90ea5b222d", size = 250054, upload-time = "2025-12-08T13:13:01.892Z" }, + { url = "https://files.pythonhosted.org/packages/19/98/8df9e1af6a493b03694a1e8070e024e7d2cdc77adedc225a35e616d505de/coverage-7.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3ab483ea0e251b5790c2aac03acde31bff0c736bf8a86829b89382b407cd1c3b", size = 249619, upload-time = "2025-12-08T13:13:03.236Z" }, + { url = "https://files.pythonhosted.org/packages/d8/71/f8679231f3353018ca66ef647fa6fe7b77e6bff7845be54ab84f86233363/coverage-7.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d84e91521c5e4cb6602fe11ece3e1de03b2760e14ae4fcf1a4b56fa3c801fcd", size = 251496, upload-time = "2025-12-08T13:13:04.511Z" }, + { url = "https://files.pythonhosted.org/packages/04/86/9cb406388034eaf3c606c22094edbbb82eea1fa9d20c0e9efadff20d0733/coverage-7.13.0-cp312-cp312-win32.whl", hash = "sha256:193c3887285eec1dbdb3f2bd7fbc351d570ca9c02ca756c3afbc71b3c98af6ef", size = 220808, upload-time = "2025-12-08T13:13:06.422Z" }, + { url = "https://files.pythonhosted.org/packages/1c/59/af483673df6455795daf5f447c2f81a3d2fcfc893a22b8ace983791f6f34/coverage-7.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:4f3e223b2b2db5e0db0c2b97286aba0036ca000f06aca9b12112eaa9af3d92ae", size = 221616, upload-time = "2025-12-08T13:13:07.95Z" }, + { url = "https://files.pythonhosted.org/packages/64/b0/959d582572b30a6830398c60dd419c1965ca4b5fb38ac6b7093a0d50ca8d/coverage-7.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:086cede306d96202e15a4b77ace8472e39d9f4e5f9fd92dd4fecdfb2313b2080", size = 220261, upload-time = "2025-12-08T13:13:09.581Z" }, + { url = "https://files.pythonhosted.org/packages/7c/cc/bce226595eb3bf7d13ccffe154c3c487a22222d87ff018525ab4dd2e9542/coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf", size = 218297, upload-time = "2025-12-08T13:13:10.977Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9f/73c4d34600aae03447dff3d7ad1d0ac649856bfb87d1ca7d681cfc913f9e/coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a", size = 218673, upload-time = "2025-12-08T13:13:12.562Z" }, + { url = "https://files.pythonhosted.org/packages/63/ab/8fa097db361a1e8586535ae5073559e6229596b3489ec3ef2f5b38df8cb2/coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74", size = 249652, upload-time = "2025-12-08T13:13:13.909Z" }, + { url = "https://files.pythonhosted.org/packages/90/3a/9bfd4de2ff191feb37ef9465855ca56a6f2f30a3bca172e474130731ac3d/coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6", size = 252251, upload-time = "2025-12-08T13:13:15.553Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/b5d8105f016e1b5874af0d7c67542da780ccd4a5f2244a433d3e20ceb1ad/coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b", size = 253492, upload-time = "2025-12-08T13:13:16.849Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b8/0fad449981803cc47a4694768b99823fb23632150743f9c83af329bb6090/coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232", size = 249850, upload-time = "2025-12-08T13:13:18.142Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e9/8d68337c3125014d918cf4327d5257553a710a2995a6a6de2ac77e5aa429/coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971", size = 251633, upload-time = "2025-12-08T13:13:19.56Z" }, + { url = "https://files.pythonhosted.org/packages/55/14/d4112ab26b3a1bc4b3c1295d8452dcf399ed25be4cf649002fb3e64b2d93/coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d", size = 249586, upload-time = "2025-12-08T13:13:20.883Z" }, + { url = "https://files.pythonhosted.org/packages/2c/a9/22b0000186db663b0d82f86c2f1028099ae9ac202491685051e2a11a5218/coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137", size = 249412, upload-time = "2025-12-08T13:13:22.22Z" }, + { url = "https://files.pythonhosted.org/packages/a1/2e/42d8e0d9e7527fba439acdc6ed24a2b97613b1dc85849b1dd935c2cffef0/coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511", size = 251191, upload-time = "2025-12-08T13:13:23.899Z" }, + { url = "https://files.pythonhosted.org/packages/a4/af/8c7af92b1377fd8860536aadd58745119252aaaa71a5213e5a8e8007a9f5/coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1", size = 220829, upload-time = "2025-12-08T13:13:25.182Z" }, + { url = "https://files.pythonhosted.org/packages/58/f9/725e8bf16f343d33cbe076c75dc8370262e194ff10072c0608b8e5cf33a3/coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a", size = 221640, upload-time = "2025-12-08T13:13:26.836Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ff/e98311000aa6933cc79274e2b6b94a2fe0fe3434fca778eba82003675496/coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6", size = 220269, upload-time = "2025-12-08T13:13:28.116Z" }, + { url = "https://files.pythonhosted.org/packages/cf/cf/bbaa2e1275b300343ea865f7d424cc0a2e2a1df6925a070b2b2d5d765330/coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a", size = 218990, upload-time = "2025-12-08T13:13:29.463Z" }, + { url = "https://files.pythonhosted.org/packages/21/1d/82f0b3323b3d149d7672e7744c116e9c170f4957e0c42572f0366dbb4477/coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8", size = 219340, upload-time = "2025-12-08T13:13:31.524Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e3/fe3fd4702a3832a255f4d43013eacb0ef5fc155a5960ea9269d8696db28b/coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053", size = 260638, upload-time = "2025-12-08T13:13:32.965Z" }, + { url = "https://files.pythonhosted.org/packages/ad/01/63186cb000307f2b4da463f72af9b85d380236965574c78e7e27680a2593/coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071", size = 262705, upload-time = "2025-12-08T13:13:34.378Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a1/c0dacef0cc865f2455d59eed3548573ce47ed603205ffd0735d1d78b5906/coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e", size = 265125, upload-time = "2025-12-08T13:13:35.73Z" }, + { url = "https://files.pythonhosted.org/packages/ef/92/82b99223628b61300bd382c205795533bed021505eab6dd86e11fb5d7925/coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493", size = 259844, upload-time = "2025-12-08T13:13:37.69Z" }, + { url = "https://files.pythonhosted.org/packages/cf/2c/89b0291ae4e6cd59ef042708e1c438e2290f8c31959a20055d8768349ee2/coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0", size = 262700, upload-time = "2025-12-08T13:13:39.525Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f9/a5f992efae1996245e796bae34ceb942b05db275e4b34222a9a40b9fbd3b/coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e", size = 260321, upload-time = "2025-12-08T13:13:41.172Z" }, + { url = "https://files.pythonhosted.org/packages/4c/89/a29f5d98c64fedbe32e2ac3c227fbf78edc01cc7572eee17d61024d89889/coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c", size = 259222, upload-time = "2025-12-08T13:13:43.282Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c3/940fe447aae302a6701ee51e53af7e08b86ff6eed7631e5740c157ee22b9/coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e", size = 261411, upload-time = "2025-12-08T13:13:44.72Z" }, + { url = "https://files.pythonhosted.org/packages/eb/31/12a4aec689cb942a89129587860ed4d0fd522d5fda81237147fde554b8ae/coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46", size = 221505, upload-time = "2025-12-08T13:13:46.332Z" }, + { url = "https://files.pythonhosted.org/packages/65/8c/3b5fe3259d863572d2b0827642c50c3855d26b3aefe80bdc9eba1f0af3b0/coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39", size = 222569, upload-time = "2025-12-08T13:13:47.79Z" }, + { url = "https://files.pythonhosted.org/packages/b0/39/f71fa8316a96ac72fc3908839df651e8eccee650001a17f2c78cdb355624/coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e", size = 220841, upload-time = "2025-12-08T13:13:49.243Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4b/9b54bedda55421449811dcd5263a2798a63f48896c24dfb92b0f1b0845bd/coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256", size = 218343, upload-time = "2025-12-08T13:13:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/59/df/c3a1f34d4bba2e592c8979f924da4d3d4598b0df2392fbddb7761258e3dc/coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a", size = 218672, upload-time = "2025-12-08T13:13:52.284Z" }, + { url = "https://files.pythonhosted.org/packages/07/62/eec0659e47857698645ff4e6ad02e30186eb8afd65214fd43f02a76537cb/coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9", size = 249715, upload-time = "2025-12-08T13:13:53.791Z" }, + { url = "https://files.pythonhosted.org/packages/23/2d/3c7ff8b2e0e634c1f58d095f071f52ed3c23ff25be524b0ccae8b71f99f8/coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19", size = 252225, upload-time = "2025-12-08T13:13:55.274Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ac/fb03b469d20e9c9a81093575003f959cf91a4a517b783aab090e4538764b/coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be", size = 253559, upload-time = "2025-12-08T13:13:57.161Z" }, + { url = "https://files.pythonhosted.org/packages/29/62/14afa9e792383c66cc0a3b872a06ded6e4ed1079c7d35de274f11d27064e/coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb", size = 249724, upload-time = "2025-12-08T13:13:58.692Z" }, + { url = "https://files.pythonhosted.org/packages/31/b7/333f3dab2939070613696ab3ee91738950f0467778c6e5a5052e840646b7/coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8", size = 251582, upload-time = "2025-12-08T13:14:00.642Z" }, + { url = "https://files.pythonhosted.org/packages/81/cb/69162bda9381f39b2287265d7e29ee770f7c27c19f470164350a38318764/coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b", size = 249538, upload-time = "2025-12-08T13:14:02.556Z" }, + { url = "https://files.pythonhosted.org/packages/e0/76/350387b56a30f4970abe32b90b2a434f87d29f8b7d4ae40d2e8a85aacfb3/coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9", size = 249349, upload-time = "2025-12-08T13:14:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/86/0d/7f6c42b8d59f4c7e43ea3059f573c0dcfed98ba46eb43c68c69e52ae095c/coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927", size = 251011, upload-time = "2025-12-08T13:14:05.505Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f1/4bb2dff379721bb0b5c649d5c5eaf438462cad824acf32eb1b7ca0c7078e/coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f", size = 221091, upload-time = "2025-12-08T13:14:07.127Z" }, + { url = "https://files.pythonhosted.org/packages/ba/44/c239da52f373ce379c194b0ee3bcc121020e397242b85f99e0afc8615066/coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc", size = 221904, upload-time = "2025-12-08T13:14:08.542Z" }, + { url = "https://files.pythonhosted.org/packages/89/1f/b9f04016d2a29c2e4a0307baefefad1a4ec5724946a2b3e482690486cade/coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b", size = 220480, upload-time = "2025-12-08T13:14:10.958Z" }, + { url = "https://files.pythonhosted.org/packages/16/d4/364a1439766c8e8647860584171c36010ca3226e6e45b1753b1b249c5161/coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28", size = 219074, upload-time = "2025-12-08T13:14:13.345Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f4/71ba8be63351e099911051b2089662c03d5671437a0ec2171823c8e03bec/coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe", size = 219342, upload-time = "2025-12-08T13:14:15.02Z" }, + { url = "https://files.pythonhosted.org/packages/5e/25/127d8ed03d7711a387d96f132589057213e3aef7475afdaa303412463f22/coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657", size = 260713, upload-time = "2025-12-08T13:14:16.907Z" }, + { url = "https://files.pythonhosted.org/packages/fd/db/559fbb6def07d25b2243663b46ba9eb5a3c6586c0c6f4e62980a68f0ee1c/coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff", size = 262825, upload-time = "2025-12-08T13:14:18.68Z" }, + { url = "https://files.pythonhosted.org/packages/37/99/6ee5bf7eff884766edb43bd8736b5e1c5144d0fe47498c3779326fe75a35/coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3", size = 265233, upload-time = "2025-12-08T13:14:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/d8/90/92f18fe0356ea69e1f98f688ed80cec39f44e9f09a1f26a1bbf017cc67f2/coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b", size = 259779, upload-time = "2025-12-08T13:14:22.367Z" }, + { url = "https://files.pythonhosted.org/packages/90/5d/b312a8b45b37a42ea7d27d7d3ff98ade3a6c892dd48d1d503e773503373f/coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d", size = 262700, upload-time = "2025-12-08T13:14:24.309Z" }, + { url = "https://files.pythonhosted.org/packages/63/f8/b1d0de5c39351eb71c366f872376d09386640840a2e09b0d03973d791e20/coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e", size = 260302, upload-time = "2025-12-08T13:14:26.068Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7c/d42f4435bc40c55558b3109a39e2d456cddcec37434f62a1f1230991667a/coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940", size = 259136, upload-time = "2025-12-08T13:14:27.604Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d3/23413241dc04d47cfe19b9a65b32a2edd67ecd0b817400c2843ebc58c847/coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2", size = 261467, upload-time = "2025-12-08T13:14:29.09Z" }, + { url = "https://files.pythonhosted.org/packages/13/e6/6e063174500eee216b96272c0d1847bf215926786f85c2bd024cf4d02d2f/coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7", size = 221875, upload-time = "2025-12-08T13:14:31.106Z" }, + { url = "https://files.pythonhosted.org/packages/3b/46/f4fb293e4cbe3620e3ac2a3e8fd566ed33affb5861a9b20e3dd6c1896cbc/coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc", size = 222982, upload-time = "2025-12-08T13:14:33.1Z" }, + { url = "https://files.pythonhosted.org/packages/68/62/5b3b9018215ed9733fbd1ae3b2ed75c5de62c3b55377a52cae732e1b7805/coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a", size = 221016, upload-time = "2025-12-08T13:14:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/8d/4c/1968f32fb9a2604645827e11ff84a31e59d532e01995f904723b4f5328b3/coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904", size = 210068, upload-time = "2025-12-08T13:14:36.236Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[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 = "distlib" +version = "0.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/91/e2df406fb4efacdf46871c25cde65d3c6ee5e173b7e5a4547a47bae91920/distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64", size = 609931, upload-time = "2023-12-12T07:14:03.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/41/9307e4f5f9976bc8b7fea0b66367734e8faf3ec84bc0d412d8cfabbb66cd/distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784", size = 468850, upload-time = "2023-12-12T07:13:59.966Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883, upload-time = "2024-07-12T22:26:00.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453, upload-time = "2024-07-12T22:25:58.476Z" }, +] + +[[package]] +name = "filelock" +version = "3.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/db/3ef5bb276dae18d6ec2124224403d1d67bccdbefc17af4cc8f553e341ab1/filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435", size = 18037, upload-time = "2024-09-17T19:02:01.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/f8/feced7779d755758a52d1f6635d990b8d98dc0a29fa568bbe0625f18fdf3/filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0", size = 16163, upload-time = "2024-09-17T19:02:00.268Z" }, +] + +[[package]] +name = "flake8" +version = "7.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mccabe" }, + { name = "pycodestyle" }, + { name = "pyflakes" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, +] + +[[package]] +name = "flake8-print" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flake8" }, + { name = "pycodestyle" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/a6/770c5832a6b563e023def7d81925d1b9f3079ebc805e48be0a5ee206f716/flake8-print-5.0.0.tar.gz", hash = "sha256:76915a2a389cc1c0879636c219eb909c38501d3a43cc8dae542081c9ba48bdf9", size = 5166, upload-time = "2022-04-30T16:19:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/2c/aa2ffda404b5d9c89dad8bcc4e0f4af673ab2de67e96997d13f04ad68b5b/flake8_print-5.0.0-py3-none-any.whl", hash = "sha256:84a1a6ea10d7056b804221ac5e62b1cee1aefc897ce16f2e5c42d3046068f5d8", size = 5687, upload-time = "2022-04-30T16:19:24.307Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "html5tagger" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/02/2ae5f46d517a2c1d4a17f2b1e4834c2c7cc0fb3a69c92389172fa16ab389/html5tagger-1.3.0.tar.gz", hash = "sha256:84fa3dfb49e5c83b79bbd856ab7b1de8e2311c3bb46a8be925f119e3880a8da9", size = 14196, upload-time = "2023-03-28T05:59:34.642Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/12/2f5d43ee912ea14a6baba4b3db6d309b02d932e3b7074c3339b4aded98ff/html5tagger-1.3.0-py3-none-any.whl", hash = "sha256:ce14313515edffec8ed8a36c5890d023922641171b4e6e5774ad1a74998f5351", size = 10956, upload-time = "2023-03-28T05:59:32.524Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/e5/c07e0bcf4ec8db8164e9f6738c048b2e66aabf30e7506f440c4cc6953f60/httptools-0.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:11d01b0ff1fe02c4c32d60af61a4d613b74fad069e47e06e9067758c01e9ac78", size = 204531, upload-time = "2025-10-10T03:54:20.887Z" }, + { url = "https://files.pythonhosted.org/packages/7e/4f/35e3a63f863a659f92ffd92bef131f3e81cf849af26e6435b49bd9f6f751/httptools-0.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84d86c1e5afdc479a6fdabf570be0d3eb791df0ae727e8dbc0259ed1249998d4", size = 109408, upload-time = "2025-10-10T03:54:22.455Z" }, + { url = "https://files.pythonhosted.org/packages/f5/71/b0a9193641d9e2471ac541d3b1b869538a5fb6419d52fd2669fa9c79e4b8/httptools-0.7.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8c751014e13d88d2be5f5f14fc8b89612fcfa92a9cc480f2bc1598357a23a05", size = 440889, upload-time = "2025-10-10T03:54:23.753Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d9/2e34811397b76718750fea44658cb0205b84566e895192115252e008b152/httptools-0.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654968cb6b6c77e37b832a9be3d3ecabb243bbe7a0b8f65fbc5b6b04c8fcabed", size = 440460, upload-time = "2025-10-10T03:54:25.313Z" }, + { url = "https://files.pythonhosted.org/packages/01/3f/a04626ebeacc489866bb4d82362c0657b2262bef381d68310134be7f40bb/httptools-0.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b580968316348b474b020edf3988eecd5d6eec4634ee6561e72ae3a2a0e00a8a", size = 425267, upload-time = "2025-10-10T03:54:26.81Z" }, + { url = "https://files.pythonhosted.org/packages/a5/99/adcd4f66614db627b587627c8ad6f4c55f18881549bab10ecf180562e7b9/httptools-0.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d496e2f5245319da9d764296e86c5bb6fcf0cf7a8806d3d000717a889c8c0b7b", size = 424429, upload-time = "2025-10-10T03:54:28.174Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/ec8fc904a8fd30ba022dfa85f3bbc64c3c7cd75b669e24242c0658e22f3c/httptools-0.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cbf8317bfccf0fed3b5680c559d3459cccf1abe9039bfa159e62e391c7270568", size = 86173, upload-time = "2025-10-10T03:54:29.5Z" }, + { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, + { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, + { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" }, + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "identify" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/bb/25024dbcc93516c492b75919e76f389bac754a3e4248682fba32b250c880/identify-2.6.1.tar.gz", hash = "sha256:91478c5fb7c3aac5ff7bf9b4344f803843dc586832d5f110d672b19aa1984c98", size = 99097, upload-time = "2024-09-14T23:50:32.513Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/0c/4ef72754c050979fdcc06c744715ae70ea37e734816bb6514f79df77a42f/identify-2.6.1-py2.py3-none-any.whl", hash = "sha256:53863bcac7caf8d2ed85bd20312ea5dcfc22226800f6d6881f232d861db5a8f0", size = 98972, upload-time = "2024-09-14T23:50:30.747Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, +] + +[[package]] +name = "isort" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, +] + +[[package]] +name = "librt" +version = "0.7.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/93/e4/b59bdf1197fdf9888452ea4d2048cdad61aef85eb83e99dc52551d7fdc04/librt-0.7.4.tar.gz", hash = "sha256:3871af56c59864d5fd21d1ac001eb2fb3b140d52ba0454720f2e4a19812404ba", size = 145862, upload-time = "2025-12-15T16:52:43.862Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/1e/3e61dff6c07a3b400fe907d3164b92b3b3023ef86eac1ee236869dc276f7/librt-0.7.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dc300cb5a5a01947b1ee8099233156fdccd5001739e5f596ecfbc0dab07b5a3b", size = 54708, upload-time = "2025-12-15T16:51:03.752Z" }, + { url = "https://files.pythonhosted.org/packages/87/98/ab2428b0a80d0fd67decaeea84a5ec920e3dd4d95ecfd074c71f51bd7315/librt-0.7.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ee8d3323d921e0f6919918a97f9b5445a7dfe647270b2629ec1008aa676c0bc0", size = 56656, upload-time = "2025-12-15T16:51:05.038Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/de1fad3a16e4fb5b6605bd6cbe6d0e5207cc8eca58993835749a1da0812b/librt-0.7.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:95cb80854a355b284c55f79674f6187cc9574df4dc362524e0cce98c89ee8331", size = 161024, upload-time = "2025-12-15T16:51:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/88/00/ddfcdc1147dd7fb68321d7b064b12f0b9101d85f466a46006f86096fde8d/librt-0.7.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ca1caedf8331d8ad6027f93b52d68ed8f8009f5c420c246a46fe9d3be06be0f", size = 169529, upload-time = "2025-12-15T16:51:07.907Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b3/915702c7077df2483b015030d1979404474f490fe9a071e9576f7b26fef6/librt-0.7.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2a6f1236151e6fe1da289351b5b5bce49651c91554ecc7b70a947bced6fe212", size = 183270, upload-time = "2025-12-15T16:51:09.164Z" }, + { url = "https://files.pythonhosted.org/packages/45/19/ab2f217e8ec509fca4ea9e2e5022b9f72c1a7b7195f5a5770d299df807ea/librt-0.7.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7766b57aeebaf3f1dac14fdd4a75c9a61f2ed56d8ebeefe4189db1cb9d2a3783", size = 179038, upload-time = "2025-12-15T16:51:10.538Z" }, + { url = "https://files.pythonhosted.org/packages/10/1c/d40851d187662cf50312ebbc0b277c7478dd78dbaaf5ee94056f1d7f2f83/librt-0.7.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:1c4c89fb01157dd0a3bfe9e75cd6253b0a1678922befcd664eca0772a4c6c979", size = 173502, upload-time = "2025-12-15T16:51:11.888Z" }, + { url = "https://files.pythonhosted.org/packages/07/52/d5880835c772b22c38db18660420fa6901fd9e9a433b65f0ba9b0f4da764/librt-0.7.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f7fa8beef580091c02b4fd26542de046b2abfe0aaefa02e8bcf68acb7618f2b3", size = 193570, upload-time = "2025-12-15T16:51:13.168Z" }, + { url = "https://files.pythonhosted.org/packages/f1/35/22d3c424b82f86ce019c0addadf001d459dfac8036aecc07fadc5c541053/librt-0.7.4-cp310-cp310-win32.whl", hash = "sha256:543c42fa242faae0466fe72d297976f3c710a357a219b1efde3a0539a68a6997", size = 42596, upload-time = "2025-12-15T16:51:14.422Z" }, + { url = "https://files.pythonhosted.org/packages/95/b1/e7c316ac5fe60ac1fdfe515198087205220803c4cf923ee63e1cb8380b17/librt-0.7.4-cp310-cp310-win_amd64.whl", hash = "sha256:25cc40d8eb63f0a7ea4c8f49f524989b9df901969cb860a2bc0e4bad4b8cb8a8", size = 48972, upload-time = "2025-12-15T16:51:15.516Z" }, + { url = "https://files.pythonhosted.org/packages/84/64/44089b12d8b4714a7f0e2f33fb19285ba87702d4be0829f20b36ebeeee07/librt-0.7.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3485b9bb7dfa66167d5500ffdafdc35415b45f0da06c75eb7df131f3357b174a", size = 54709, upload-time = "2025-12-15T16:51:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/26/ef/6fa39fb5f37002f7d25e0da4f24d41b457582beea9369eeb7e9e73db5508/librt-0.7.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:188b4b1a770f7f95ea035d5bbb9d7367248fc9d12321deef78a269ebf46a5729", size = 56663, upload-time = "2025-12-15T16:51:17.856Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e4/cbaca170a13bee2469c90df9e47108610b4422c453aea1aec1779ac36c24/librt-0.7.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1b668b1c840183e4e38ed5a99f62fac44c3a3eef16870f7f17cfdfb8b47550ed", size = 161703, upload-time = "2025-12-15T16:51:19.421Z" }, + { url = "https://files.pythonhosted.org/packages/d0/32/0b2296f9cc7e693ab0d0835e355863512e5eac90450c412777bd699c76ae/librt-0.7.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0e8f864b521f6cfedb314d171630f827efee08f5c3462bcbc2244ab8e1768cd6", size = 171027, upload-time = "2025-12-15T16:51:20.721Z" }, + { url = "https://files.pythonhosted.org/packages/d8/33/c70b6d40f7342716e5f1353c8da92d9e32708a18cbfa44897a93ec2bf879/librt-0.7.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df7c9def4fc619a9c2ab402d73a0c5b53899abe090e0100323b13ccb5a3dd82", size = 184700, upload-time = "2025-12-15T16:51:22.272Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c8/555c405155da210e4c4113a879d378f54f850dbc7b794e847750a8fadd43/librt-0.7.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f79bc3595b6ed159a1bf0cdc70ed6ebec393a874565cab7088a219cca14da727", size = 180719, upload-time = "2025-12-15T16:51:23.561Z" }, + { url = "https://files.pythonhosted.org/packages/6b/88/34dc1f1461c5613d1b73f0ecafc5316cc50adcc1b334435985b752ed53e5/librt-0.7.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:77772a4b8b5f77d47d883846928c36d730b6e612a6388c74cba33ad9eb149c11", size = 174535, upload-time = "2025-12-15T16:51:25.031Z" }, + { url = "https://files.pythonhosted.org/packages/b6/5a/f3fafe80a221626bcedfa9fe5abbf5f04070989d44782f579b2d5920d6d0/librt-0.7.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:064a286e6ab0b4c900e228ab4fa9cb3811b4b83d3e0cc5cd816b2d0f548cb61c", size = 195236, upload-time = "2025-12-15T16:51:26.328Z" }, + { url = "https://files.pythonhosted.org/packages/d8/77/5c048d471ce17f4c3a6e08419be19add4d291e2f7067b877437d482622ac/librt-0.7.4-cp311-cp311-win32.whl", hash = "sha256:42da201c47c77b6cc91fc17e0e2b330154428d35d6024f3278aa2683e7e2daf2", size = 42930, upload-time = "2025-12-15T16:51:27.853Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3b/514a86305a12c3d9eac03e424b07cd312c7343a9f8a52719aa079590a552/librt-0.7.4-cp311-cp311-win_amd64.whl", hash = "sha256:d31acb5886c16ae1711741f22504195af46edec8315fe69b77e477682a87a83e", size = 49240, upload-time = "2025-12-15T16:51:29.037Z" }, + { url = "https://files.pythonhosted.org/packages/ba/01/3b7b1914f565926b780a734fac6e9a4d2c7aefe41f4e89357d73697a9457/librt-0.7.4-cp311-cp311-win_arm64.whl", hash = "sha256:114722f35093da080a333b3834fff04ef43147577ed99dd4db574b03a5f7d170", size = 42613, upload-time = "2025-12-15T16:51:30.194Z" }, + { url = "https://files.pythonhosted.org/packages/f3/e7/b805d868d21f425b7e76a0ea71a2700290f2266a4f3c8357fcf73efc36aa/librt-0.7.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7dd3b5c37e0fb6666c27cf4e2c88ae43da904f2155c4cfc1e5a2fdce3b9fcf92", size = 55688, upload-time = "2025-12-15T16:51:31.571Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/69a2b02e62a14cfd5bfd9f1e9adea294d5bcfeea219c7555730e5d068ee4/librt-0.7.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9c5de1928c486201b23ed0cc4ac92e6e07be5cd7f3abc57c88a9cf4f0f32108", size = 57141, upload-time = "2025-12-15T16:51:32.714Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6b/05dba608aae1272b8ea5ff8ef12c47a4a099a04d1e00e28a94687261d403/librt-0.7.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:078ae52ffb3f036396cc4aed558e5b61faedd504a3c1f62b8ae34bf95ae39d94", size = 165322, upload-time = "2025-12-15T16:51:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/8f/bc/199533d3fc04a4cda8d7776ee0d79955ab0c64c79ca079366fbc2617e680/librt-0.7.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce58420e25097b2fc201aef9b9f6d65df1eb8438e51154e1a7feb8847e4a55ab", size = 174216, upload-time = "2025-12-15T16:51:35.384Z" }, + { url = "https://files.pythonhosted.org/packages/62/ec/09239b912a45a8ed117cb4a6616d9ff508f5d3131bd84329bf2f8d6564f1/librt-0.7.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b719c8730c02a606dc0e8413287e8e94ac2d32a51153b300baf1f62347858fba", size = 189005, upload-time = "2025-12-15T16:51:36.687Z" }, + { url = "https://files.pythonhosted.org/packages/46/2e/e188313d54c02f5b0580dd31476bb4b0177514ff8d2be9f58d4a6dc3a7ba/librt-0.7.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3749ef74c170809e6dee68addec9d2458700a8de703de081c888e92a8b015cf9", size = 183960, upload-time = "2025-12-15T16:51:37.977Z" }, + { url = "https://files.pythonhosted.org/packages/eb/84/f1d568d254518463d879161d3737b784137d236075215e56c7c9be191cee/librt-0.7.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b35c63f557653c05b5b1b6559a074dbabe0afee28ee2a05b6c9ba21ad0d16a74", size = 177609, upload-time = "2025-12-15T16:51:40.584Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/060bbc1c002f0d757c33a1afe6bf6a565f947a04841139508fc7cef6c08b/librt-0.7.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1ef704e01cb6ad39ad7af668d51677557ca7e5d377663286f0ee1b6b27c28e5f", size = 199269, upload-time = "2025-12-15T16:51:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/ff/7f/708f8f02d8012ee9f366c07ea6a92882f48bd06cc1ff16a35e13d0fbfb08/librt-0.7.4-cp312-cp312-win32.whl", hash = "sha256:c66c2b245926ec15188aead25d395091cb5c9df008d3b3207268cd65557d6286", size = 43186, upload-time = "2025-12-15T16:51:43.149Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a5/4e051b061c8b2509be31b2c7ad4682090502c0a8b6406edcf8c6b4fe1ef7/librt-0.7.4-cp312-cp312-win_amd64.whl", hash = "sha256:71a56f4671f7ff723451f26a6131754d7c1809e04e22ebfbac1db8c9e6767a20", size = 49455, upload-time = "2025-12-15T16:51:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d2/90d84e9f919224a3c1f393af1636d8638f54925fdc6cd5ee47f1548461e5/librt-0.7.4-cp312-cp312-win_arm64.whl", hash = "sha256:419eea245e7ec0fe664eb7e85e7ff97dcdb2513ca4f6b45a8ec4a3346904f95a", size = 42828, upload-time = "2025-12-15T16:51:45.498Z" }, + { url = "https://files.pythonhosted.org/packages/fe/4d/46a53ccfbb39fd0b493fd4496eb76f3ebc15bb3e45d8c2e695a27587edf5/librt-0.7.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d44a1b1ba44cbd2fc3cb77992bef6d6fdb1028849824e1dd5e4d746e1f7f7f0b", size = 55745, upload-time = "2025-12-15T16:51:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/7f/2b/3ac7f5212b1828bf4f979cf87f547db948d3e28421d7a430d4db23346ce4/librt-0.7.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c9cab4b3de1f55e6c30a84c8cee20e4d3b2476f4d547256694a1b0163da4fe32", size = 57166, upload-time = "2025-12-15T16:51:48.219Z" }, + { url = "https://files.pythonhosted.org/packages/e8/99/6523509097cbe25f363795f0c0d1c6a3746e30c2994e25b5aefdab119b21/librt-0.7.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2857c875f1edd1feef3c371fbf830a61b632fb4d1e57160bb1e6a3206e6abe67", size = 165833, upload-time = "2025-12-15T16:51:49.443Z" }, + { url = "https://files.pythonhosted.org/packages/fe/35/323611e59f8fe032649b4fb7e77f746f96eb7588fcbb31af26bae9630571/librt-0.7.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b370a77be0a16e1ad0270822c12c21462dc40496e891d3b0caf1617c8cc57e20", size = 174818, upload-time = "2025-12-15T16:51:51.015Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/40fb2bb21616c6e06b6a64022802228066e9a31618f493e03f6b9661548a/librt-0.7.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d05acd46b9a52087bfc50c59dfdf96a2c480a601e8898a44821c7fd676598f74", size = 189607, upload-time = "2025-12-15T16:51:52.671Z" }, + { url = "https://files.pythonhosted.org/packages/32/48/1b47c7d5d28b775941e739ed2bfe564b091c49201b9503514d69e4ed96d7/librt-0.7.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:70969229cb23d9c1a80e14225838d56e464dc71fa34c8342c954fc50e7516dee", size = 184585, upload-time = "2025-12-15T16:51:54.027Z" }, + { url = "https://files.pythonhosted.org/packages/75/a6/ee135dfb5d3b54d5d9001dbe483806229c6beac3ee2ba1092582b7efeb1b/librt-0.7.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4450c354b89dbb266730893862dbff06006c9ed5b06b6016d529b2bf644fc681", size = 178249, upload-time = "2025-12-15T16:51:55.248Z" }, + { url = "https://files.pythonhosted.org/packages/04/87/d5b84ec997338be26af982bcd6679be0c1db9a32faadab1cf4bb24f9e992/librt-0.7.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:adefe0d48ad35b90b6f361f6ff5a1bd95af80c17d18619c093c60a20e7a5b60c", size = 199851, upload-time = "2025-12-15T16:51:56.933Z" }, + { url = "https://files.pythonhosted.org/packages/86/63/ba1333bf48306fe398e3392a7427ce527f81b0b79d0d91618c4610ce9d15/librt-0.7.4-cp313-cp313-win32.whl", hash = "sha256:21ea710e96c1e050635700695095962a22ea420d4b3755a25e4909f2172b4ff2", size = 43249, upload-time = "2025-12-15T16:51:58.498Z" }, + { url = "https://files.pythonhosted.org/packages/f9/8a/de2c6df06cdfa9308c080e6b060fe192790b6a48a47320b215e860f0e98c/librt-0.7.4-cp313-cp313-win_amd64.whl", hash = "sha256:772e18696cf5a64afee908662fbcb1f907460ddc851336ee3a848ef7684c8e1e", size = 49417, upload-time = "2025-12-15T16:51:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/31/66/8ee0949efc389691381ed686185e43536c20e7ad880c122dd1f31e65c658/librt-0.7.4-cp313-cp313-win_arm64.whl", hash = "sha256:52e34c6af84e12921748c8354aa6acf1912ca98ba60cdaa6920e34793f1a0788", size = 42824, upload-time = "2025-12-15T16:52:00.784Z" }, + { url = "https://files.pythonhosted.org/packages/74/81/6921e65c8708eb6636bbf383aa77e6c7dad33a598ed3b50c313306a2da9d/librt-0.7.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4f1ee004942eaaed6e06c087d93ebc1c67e9a293e5f6b9b5da558df6bf23dc5d", size = 55191, upload-time = "2025-12-15T16:52:01.97Z" }, + { url = "https://files.pythonhosted.org/packages/0d/d6/3eb864af8a8de8b39cc8dd2e9ded1823979a27795d72c4eea0afa8c26c9f/librt-0.7.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d854c6dc0f689bad7ed452d2a3ecff58029d80612d336a45b62c35e917f42d23", size = 56898, upload-time = "2025-12-15T16:52:03.356Z" }, + { url = "https://files.pythonhosted.org/packages/49/bc/b1d4c0711fdf79646225d576faee8747b8528a6ec1ceb6accfd89ade7102/librt-0.7.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a4f7339d9e445280f23d63dea842c0c77379c4a47471c538fc8feedab9d8d063", size = 163725, upload-time = "2025-12-15T16:52:04.572Z" }, + { url = "https://files.pythonhosted.org/packages/2c/08/61c41cd8f0a6a41fc99ea78a2205b88187e45ba9800792410ed62f033584/librt-0.7.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39003fc73f925e684f8521b2dbf34f61a5deb8a20a15dcf53e0d823190ce8848", size = 172469, upload-time = "2025-12-15T16:52:05.863Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c7/4ee18b4d57f01444230bc18cf59103aeab8f8c0f45e84e0e540094df1df1/librt-0.7.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bb15ee29d95875ad697d449fe6071b67f730f15a6961913a2b0205015ca0843", size = 186804, upload-time = "2025-12-15T16:52:07.192Z" }, + { url = "https://files.pythonhosted.org/packages/a1/af/009e8ba3fbf830c936842da048eda1b34b99329f402e49d88fafff6525d1/librt-0.7.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:02a69369862099e37d00765583052a99d6a68af7e19b887e1b78fee0146b755a", size = 181807, upload-time = "2025-12-15T16:52:08.554Z" }, + { url = "https://files.pythonhosted.org/packages/85/26/51ae25f813656a8b117c27a974f25e8c1e90abcd5a791ac685bf5b489a1b/librt-0.7.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ec72342cc4d62f38b25a94e28b9efefce41839aecdecf5e9627473ed04b7be16", size = 175595, upload-time = "2025-12-15T16:52:10.186Z" }, + { url = "https://files.pythonhosted.org/packages/48/93/36d6c71f830305f88996b15c8e017aa8d1e03e2e947b40b55bbf1a34cf24/librt-0.7.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:776dbb9bfa0fc5ce64234b446995d8d9f04badf64f544ca036bd6cff6f0732ce", size = 196504, upload-time = "2025-12-15T16:52:11.472Z" }, + { url = "https://files.pythonhosted.org/packages/08/11/8299e70862bb9d704735bf132c6be09c17b00fbc7cda0429a9df222fdc1b/librt-0.7.4-cp314-cp314-win32.whl", hash = "sha256:0f8cac84196d0ffcadf8469d9ded4d4e3a8b1c666095c2a291e22bf58e1e8a9f", size = 39738, upload-time = "2025-12-15T16:52:12.962Z" }, + { url = "https://files.pythonhosted.org/packages/54/d5/656b0126e4e0f8e2725cd2d2a1ec40f71f37f6f03f135a26b663c0e1a737/librt-0.7.4-cp314-cp314-win_amd64.whl", hash = "sha256:037f5cb6fe5abe23f1dc058054d50e9699fcc90d0677eee4e4f74a8677636a1a", size = 45976, upload-time = "2025-12-15T16:52:14.441Z" }, + { url = "https://files.pythonhosted.org/packages/60/86/465ff07b75c1067da8fa7f02913c4ead096ef106cfac97a977f763783bfb/librt-0.7.4-cp314-cp314-win_arm64.whl", hash = "sha256:a5deebb53d7a4d7e2e758a96befcd8edaaca0633ae71857995a0f16033289e44", size = 39073, upload-time = "2025-12-15T16:52:15.621Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a0/24941f85960774a80d4b3c2aec651d7d980466da8101cae89e8b032a3e21/librt-0.7.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b4c25312c7f4e6ab35ab16211bdf819e6e4eddcba3b2ea632fb51c9a2a97e105", size = 57369, upload-time = "2025-12-15T16:52:16.782Z" }, + { url = "https://files.pythonhosted.org/packages/77/a0/ddb259cae86ab415786c1547d0fe1b40f04a7b089f564fd5c0242a3fafb2/librt-0.7.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:618b7459bb392bdf373f2327e477597fff8f9e6a1878fffc1b711c013d1b0da4", size = 59230, upload-time = "2025-12-15T16:52:18.259Z" }, + { url = "https://files.pythonhosted.org/packages/31/11/77823cb530ab8a0c6fac848ac65b745be446f6f301753b8990e8809080c9/librt-0.7.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1437c3f72a30c7047f16fd3e972ea58b90172c3c6ca309645c1c68984f05526a", size = 183869, upload-time = "2025-12-15T16:52:19.457Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ce/157db3614cf3034b3f702ae5ba4fefda4686f11eea4b7b96542324a7a0e7/librt-0.7.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c96cb76f055b33308f6858b9b594618f1b46e147a4d03a4d7f0c449e304b9b95", size = 194606, upload-time = "2025-12-15T16:52:20.795Z" }, + { url = "https://files.pythonhosted.org/packages/30/ef/6ec4c7e3d6490f69a4fd2803516fa5334a848a4173eac26d8ee6507bff6e/librt-0.7.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28f990e6821204f516d09dc39966ef8b84556ffd648d5926c9a3f681e8de8906", size = 206776, upload-time = "2025-12-15T16:52:22.229Z" }, + { url = "https://files.pythonhosted.org/packages/ad/22/750b37bf549f60a4782ab80e9d1e9c44981374ab79a7ea68670159905918/librt-0.7.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc4aebecc79781a1b77d7d4e7d9fe080385a439e198d993b557b60f9117addaf", size = 203205, upload-time = "2025-12-15T16:52:23.603Z" }, + { url = "https://files.pythonhosted.org/packages/7a/87/2e8a0f584412a93df5faad46c5fa0a6825fdb5eba2ce482074b114877f44/librt-0.7.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:022cc673e69283a42621dd453e2407cf1647e77f8bd857d7ad7499901e62376f", size = 196696, upload-time = "2025-12-15T16:52:24.951Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ca/7bf78fa950e43b564b7de52ceeb477fb211a11f5733227efa1591d05a307/librt-0.7.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2b3ca211ae8ea540569e9c513da052699b7b06928dcda61247cb4f318122bdb5", size = 217191, upload-time = "2025-12-15T16:52:26.194Z" }, + { url = "https://files.pythonhosted.org/packages/d6/49/3732b0e8424ae35ad5c3166d9dd5bcdae43ce98775e0867a716ff5868064/librt-0.7.4-cp314-cp314t-win32.whl", hash = "sha256:8a461f6456981d8c8e971ff5a55f2e34f4e60871e665d2f5fde23ee74dea4eeb", size = 40276, upload-time = "2025-12-15T16:52:27.54Z" }, + { url = "https://files.pythonhosted.org/packages/35/d6/d8823e01bd069934525fddb343189c008b39828a429b473fb20d67d5cd36/librt-0.7.4-cp314-cp314t-win_amd64.whl", hash = "sha256:721a7b125a817d60bf4924e1eec2a7867bfcf64cfc333045de1df7a0629e4481", size = 46772, upload-time = "2025-12-15T16:52:28.653Z" }, + { url = "https://files.pythonhosted.org/packages/36/e9/a0aa60f5322814dd084a89614e9e31139702e342f8459ad8af1984a18168/librt-0.7.4-cp314-cp314t-win_arm64.whl", hash = "sha256:76b2ba71265c0102d11458879b4d53ccd0b32b0164d14deb8d2b598a018e502f", size = 39724, upload-time = "2025-12-15T16:52:29.836Z" }, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/0b/19348d4c98980c4851d2f943f8ebafdece2ae7ef737adcfa5994ce8e5f10/multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5", size = 77176, upload-time = "2026-01-26T02:42:59.784Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/9de3f8077852e3d438215c81e9b691244532d2e05b4270e89ce67b7d103c/multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8", size = 44996, upload-time = "2026-01-26T02:43:01.674Z" }, + { url = "https://files.pythonhosted.org/packages/31/5c/08c7f7fe311f32e83f7621cd3f99d805f45519cd06fafb247628b861da7d/multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872", size = 44631, upload-time = "2026-01-26T02:43:03.169Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/0e3b1390ae772f27501199996b94b52ceeb64fe6f9120a32c6c3f6b781be/multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991", size = 242561, upload-time = "2026-01-26T02:43:04.733Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f4/8719f4f167586af317b69dd3e90f913416c91ca610cac79a45c53f590312/multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03", size = 242223, upload-time = "2026-01-26T02:43:06.695Z" }, + { url = "https://files.pythonhosted.org/packages/47/ab/7c36164cce64a6ad19c6d9a85377b7178ecf3b89f8fd589c73381a5eedfd/multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981", size = 222322, upload-time = "2026-01-26T02:43:08.472Z" }, + { url = "https://files.pythonhosted.org/packages/f5/79/a25add6fb38035b5337bc5734f296d9afc99163403bbcf56d4170f97eb62/multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6", size = 254005, upload-time = "2026-01-26T02:43:10.127Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7b/64a87cf98e12f756fc8bd444b001232ffff2be37288f018ad0d3f0aae931/multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190", size = 251173, upload-time = "2026-01-26T02:43:11.731Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ac/b605473de2bb404e742f2cc3583d12aedb2352a70e49ae8fce455b50c5aa/multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92", size = 243273, upload-time = "2026-01-26T02:43:13.063Z" }, + { url = "https://files.pythonhosted.org/packages/03/65/11492d6a0e259783720f3bc1d9ea55579a76f1407e31ed44045c99542004/multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee", size = 238956, upload-time = "2026-01-26T02:43:14.843Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a7/7ee591302af64e7c196fb63fe856c788993c1372df765102bd0448e7e165/multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2", size = 233477, upload-time = "2026-01-26T02:43:16.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/99/c109962d58756c35fd9992fed7f2355303846ea2ff054bb5f5e9d6b888de/multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568", size = 243615, upload-time = "2026-01-26T02:43:17.84Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5f/1973e7c771c86e93dcfe1c9cc55a5481b610f6614acfc28c0d326fe6bfad/multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40", size = 249930, upload-time = "2026-01-26T02:43:19.06Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a5/f170fc2268c3243853580203378cd522446b2df632061e0a5409817854c7/multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962", size = 243807, upload-time = "2026-01-26T02:43:20.286Z" }, + { url = "https://files.pythonhosted.org/packages/de/01/73856fab6d125e5bc652c3986b90e8699a95e84b48d72f39ade6c0e74a8c/multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505", size = 239103, upload-time = "2026-01-26T02:43:21.508Z" }, + { url = "https://files.pythonhosted.org/packages/e7/46/f1220bd9944d8aa40d8ccff100eeeee19b505b857b6f603d6078cb5315b0/multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122", size = 41416, upload-time = "2026-01-26T02:43:22.703Z" }, + { url = "https://files.pythonhosted.org/packages/68/00/9b38e272a770303692fc406c36e1a4c740f401522d5787691eb38a8925a8/multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df", size = 46022, upload-time = "2026-01-26T02:43:23.77Z" }, + { url = "https://files.pythonhosted.org/packages/64/65/d8d42490c02ee07b6bbe00f7190d70bb4738b3cce7629aaf9f213ef730dd/multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db", size = 43238, upload-time = "2026-01-26T02:43:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, + { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, + { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, + { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, + { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433, upload-time = "2023-02-04T12:11:27.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695, upload-time = "2023-02-04T12:11:25.002Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + +[[package]] +name = "packaging" +version = "24.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/65/50db4dda066951078f0a96cf12f4b9ada6e4b811516bf0262c0f4f7064d4/packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", size = 148788, upload-time = "2024-06-09T23:19:24.956Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/aa/cc0199a5f0ad350994d660967a8efb233fe0416e4639146c089643407ce6/packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124", size = 53985, upload-time = "2024-06-09T23:19:21.909Z" }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "pep8-naming" +version = "0.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flake8" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/59/c32862134635ba231d45f1711035550dc38246396c27269a4cde4bfe18d2/pep8_naming-0.15.1.tar.gz", hash = "sha256:f6f4a499aba2deeda93c1f26ccc02f3da32b035c8b2db9696b730ef2c9639d29", size = 17640, upload-time = "2025-05-05T20:43:12.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/78/25281540f1121acaa78926f599a17ce102b8971bc20b096fa7fb6b5b59c1/pep8_naming-0.15.1-py3-none-any.whl", hash = "sha256:eb63925e7fd9e028c7f7ee7b1e413ec03d1ee5de0e627012102ee0222c273c86", size = 9561, upload-time = "2025-05-05T20:43:11.626Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.3.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302, upload-time = "2024-09-17T19:06:50.688Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439, upload-time = "2024-09-17T19:06:49.212Z" }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955, upload-time = "2024-04-20T21:34:42.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, +] + +[[package]] +name = "pycodestyle" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pyflakes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +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 = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199, upload-time = "2024-08-06T20:31:40.178Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758, upload-time = "2024-08-06T20:31:42.173Z" }, + { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463, upload-time = "2024-08-06T20:31:44.263Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280, upload-time = "2024-08-06T20:31:50.199Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239, upload-time = "2024-08-06T20:31:52.292Z" }, + { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802, upload-time = "2024-08-06T20:31:53.836Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527, upload-time = "2024-08-06T20:31:55.565Z" }, + { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052, upload-time = "2024-08-06T20:31:56.914Z" }, + { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774, upload-time = "2024-08-06T20:31:58.304Z" }, + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload-time = "2024-08-06T20:32:08.338Z" }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload-time = "2024-08-06T20:32:14.124Z" }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload-time = "2024-08-06T20:32:16.17Z" }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload-time = "2024-08-06T20:32:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload-time = "2024-08-06T20:32:19.889Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload-time = "2024-08-06T20:32:21.273Z" }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" }, + { url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" }, + { url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" }, + { url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" }, + { url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" }, + { url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" }, + { url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, +] + +[[package]] +name = "sanic" +version = "25.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "html5tagger" }, + { name = "httptools" }, + { name = "multidict" }, + { name = "sanic-routing" }, + { name = "setuptools" }, + { name = "tracerite" }, + { name = "typing-extensions" }, + { name = "ujson", marker = "implementation_name == 'cpython' and sys_platform != 'win32'" }, + { name = "uvloop", marker = "implementation_name == 'cpython' and sys_platform != 'win32'" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/f6/5e9ba853d2872119a252bff0cad712c015c1ed5318cceab5da68c7d2f1c4/sanic-25.12.0.tar.gz", hash = "sha256:ec124338f83a781da8095ed2676e60eb40c7fe21e7aa649a879f8860b4c7bd7a", size = 373452, upload-time = "2025-12-31T19:36:49.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/8a/16adaf66d358abfd0d24f2b76857196cf7effbf75c01306306bf39904e30/sanic-25.12.0-py3-none-any.whl", hash = "sha256:42ccf717f564aadab529a1522c489a709c4971c8123793ae07852aa110f8a913", size = 257787, upload-time = "2025-12-31T19:36:47.406Z" }, +] + +[[package]] +name = "sanic-routing" +version = "23.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/5c/2a7edd14fbccca3719a8d680951d4b25f986752c781c61ccf156a6d1ebff/sanic-routing-23.12.0.tar.gz", hash = "sha256:1dcadc62c443e48c852392dba03603f9862b6197fc4cba5bbefeb1ace0848b04", size = 29473, upload-time = "2023-12-31T09:28:36.992Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/e3/3425c9a8773807ac2c01d6a56c8521733f09b627e5827e733c5cd36b9ac5/sanic_routing-23.12.0-py3-none-any.whl", hash = "sha256:1558a72afcb9046ed3134a5edae02fc1552cff08f0fff2e8d5de0877ea43ed73", size = 25522, upload-time = "2023-12-31T09:28:35.233Z" }, +] + +[[package]] +name = "sanic-testing" +version = "24.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/56/8d31d8a7e0b61633d6358694edfae976e69739b5bd640ceac7989b62e749/sanic_testing-24.6.0.tar.gz", hash = "sha256:7591ce537e2a651efb6dc01b458e7e4ea5347f6d91438676774c6f505a124731", size = 10871, upload-time = "2024-06-30T12:13:31.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/93/1d588f1cb9b710b9f22fa78b53d699a8062edc94204d50dd0d78c5f5b495/sanic_testing-24.6.0-py3-none-any.whl", hash = "sha256:b1027184735e88230891aa0461fff84093abfa3bff0f4d29c0f78f42e59efada", size = 10326, upload-time = "2024-06-30T12:13:30.014Z" }, +] + +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + +[[package]] +name = "six" +version = "1.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", size = 34041, upload-time = "2021-05-05T14:18:18.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053, upload-time = "2021-05-05T14:18:17.237Z" }, +] + +[[package]] +name = "tomli" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/3f/d7af728f075fb08564c5949a9c95e44352e23dee646869fa104a3b2060a3/tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f", size = 15164, upload-time = "2022-02-08T10:54:04.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", size = 12757, upload-time = "2022-02-08T10:54:02.017Z" }, +] + +[[package]] +name = "tracerite" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "html5tagger" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/b9/89b065c1818e5973c333a33311f823954ff4c7c48440c20b37669c5b752c/tracerite-2.3.1.tar.gz", hash = "sha256:f46ee672d240d500a2331781b09eb33564d473f6ae60cd871ebce6c2413cffa8", size = 61303, upload-time = "2025-12-30T22:51:19.32Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/62/3f385a67ff3cc91209f107d20bbebdecf7a4e4aba55a43f9f71bddc424a9/tracerite-2.3.1-py3-none-any.whl", hash = "sha256:5f9595ba90f075b58e14a9baf84d8204fec3cdce50029f1c32d757af79d9ccbe", size = 65884, upload-time = "2025-12-30T22:51:18.1Z" }, +] + +[[package]] +name = "types-python-dateutil" +version = "2.9.0.20260305" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/c7/025c624f347e10476b439a6619a95f1d200250ea88e7ccea6e09e48a7544/types_python_dateutil-2.9.0.20260305.tar.gz", hash = "sha256:389717c9f64d8f769f36d55a01873915b37e97e52ce21928198d210fbd393c8b", size = 16885, upload-time = "2026-03-05T04:00:47.409Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/77/8c0d1ec97f0d9707ad3d8fa270ab8964e7b31b076d2f641c94987395cc75/types_python_dateutil-2.9.0.20260305-py3-none-any.whl", hash = "sha256:a3be9ca444d38cadabd756cfbb29780d8b338ae2a3020e73c266a83cc3025dd7", size = 18419, upload-time = "2026-03-05T04:00:46.392Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "ujson" +version = "5.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/3e/c35530c5ffc25b71c59ae0cd7b8f99df37313daa162ce1e2f7925f7c2877/ujson-5.12.0.tar.gz", hash = "sha256:14b2e1eb528d77bc0f4c5bd1a7ebc05e02b5b41beefb7e8567c9675b8b13bcf4", size = 7158451, upload-time = "2026-03-11T22:19:30.397Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/ee/45c7c1f9268b0fecdd68f9ada490bc09632b74f5f90a9be759e51a746ddc/ujson-5.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:38051f36423f084b909aaadb3b41c9c6a2958e86956ba21a8489636911e87504", size = 56145, upload-time = "2026-03-11T22:17:49.409Z" }, + { url = "https://files.pythonhosted.org/packages/6d/dc/ed181dbfb2beee598e91280c6903ba71e10362b051716317e2d3664614bb/ujson-5.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:457fabc2700a8e6ddb85bc5a1d30d3345fe0d3ec3ee8161a4e032ec585801dfa", size = 53839, upload-time = "2026-03-11T22:17:50.973Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d8/eb9ef42c660f431deeedc2e1b09c4ba29aa22818a439ddda7da6ae23ddfa/ujson-5.12.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57930ac9519099b852e190d2c04b1fb5d97ea128db33bce77ed874eccb4c7f09", size = 57844, upload-time = "2026-03-11T22:17:53.029Z" }, + { url = "https://files.pythonhosted.org/packages/68/37/0b586d079d3f2a5be5aa58ab5c423cbb4fae2ee4e65369c87aa74ac7e113/ujson-5.12.0-cp310-cp310-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:9b3b86ec3e818f3dd3e13a9de628e88a9990f4af68ecb0b12dd3de81227f0a26", size = 59923, upload-time = "2026-03-11T22:17:54.332Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/6a4b69eb397502767f438b5a2b4c066dccc9e3b263115f5ee07510250fc7/ujson-5.12.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:460e76a4daff214ae33ab959494962c93918cb44714ea3e3f748b14aa37f8a87", size = 57427, upload-time = "2026-03-11T22:17:55.317Z" }, + { url = "https://files.pythonhosted.org/packages/bb/4b/ae118440a72e85e68ee8dd26cfc47ea7857954a3341833cde9da7dc40ca3/ujson-5.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e584d0cdd37cac355aca52ed788d1a2d939d6837e2870d3b70e585db24025a50", size = 1037301, upload-time = "2026-03-11T22:17:56.427Z" }, + { url = "https://files.pythonhosted.org/packages/c2/76/834caa7905f65d3a695e4f5ff8d5d4a98508e396a9e8ab0739ab4fe2d422/ujson-5.12.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0fe9128e75c6aa6e9ae06c1408d6edd9179a2fef0fe6d9cda3166b887eba521d", size = 1196664, upload-time = "2026-03-11T22:17:58.061Z" }, + { url = "https://files.pythonhosted.org/packages/f2/33/1f3c1543c1d3f18c54bb3f8c1e74314fd6ad3c1aa375f01433e89a86bfa6/ujson-5.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3ed5cb149892141b1e77ef312924a327f2cc718b34247dae346ed66329e1b8be", size = 1089668, upload-time = "2026-03-11T22:17:59.617Z" }, + { url = "https://files.pythonhosted.org/packages/10/22/fd22e2f6766bae934d3050517ca47d463016bd8688508d1ecc1baa18a7ad/ujson-5.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:58a11cb49482f1a095a2bd9a1d81dd7c8fb5d2357f959ece85db4e46a825fd00", size = 56139, upload-time = "2026-03-11T22:18:04.591Z" }, + { url = "https://files.pythonhosted.org/packages/c6/fd/6839adff4fc0164cbcecafa2857ba08a6eaeedd7e098d6713cb899a91383/ujson-5.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9b3cf13facf6f77c283af0e1713e5e8c47a0fe295af81326cb3cb4380212e797", size = 53836, upload-time = "2026-03-11T22:18:05.662Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b0/0c19faac62d68ceeffa83a08dc3d71b8462cf5064d0e7e0b15ba19898dad/ujson-5.12.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb94245a715b4d6e24689de12772b85329a1f9946cbf6187923a64ecdea39e65", size = 57851, upload-time = "2026-03-11T22:18:06.744Z" }, + { url = "https://files.pythonhosted.org/packages/04/f6/e7fd283788de73b86e99e08256726bb385923249c21dcd306e59d532a1a1/ujson-5.12.0-cp311-cp311-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:0fe6b8b8968e11dd9b2348bd508f0f57cf49ab3512064b36bc4117328218718e", size = 59906, upload-time = "2026-03-11T22:18:07.791Z" }, + { url = "https://files.pythonhosted.org/packages/d7/3a/b100735a2b43ee6e8fe4c883768e362f53576f964d4ea841991060aeaf35/ujson-5.12.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89e302abd3749f6d6699691747969a5d85f7c73081d5ed7e2624c7bd9721a2ab", size = 57409, upload-time = "2026-03-11T22:18:08.79Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/f97cc20c99ca304662191b883ae13ae02912ca7244710016ba0cb8a5be34/ujson-5.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0727363b05ab05ee737a28f6200dc4078bce6b0508e10bd8aab507995a15df61", size = 1037339, upload-time = "2026-03-11T22:18:10.424Z" }, + { url = "https://files.pythonhosted.org/packages/10/7a/53ddeda0ffe1420db2f9999897b3cbb920fbcff1849d1f22b196d0f34785/ujson-5.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b62cb9a7501e1f5c9ffe190485501349c33e8862dde4377df774e40b8166871f", size = 1196625, upload-time = "2026-03-11T22:18:11.82Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1a/4c64a6bef522e9baf195dd5be151bc815cd4896c50c6e2489599edcda85f/ujson-5.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a6ec5bf6bc361f2f0f9644907a36ce527715b488988a8df534120e5c34eeda94", size = 1089669, upload-time = "2026-03-11T22:18:13.343Z" }, + { url = "https://files.pythonhosted.org/packages/84/f6/ac763d2108d28f3a40bb3ae7d2fafab52ca31b36c2908a4ad02cd3ceba2a/ujson-5.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:09b4beff9cc91d445d5818632907b85fb06943b61cb346919ce202668bf6794a", size = 56326, upload-time = "2026-03-11T22:18:18.467Z" }, + { url = "https://files.pythonhosted.org/packages/25/46/d0b3af64dcdc549f9996521c8be6d860ac843a18a190ffc8affeb7259687/ujson-5.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ca0c7ce828bb76ab78b3991904b477c2fd0f711d7815c252d1ef28ff9450b052", size = 53910, upload-time = "2026-03-11T22:18:19.502Z" }, + { url = "https://files.pythonhosted.org/packages/9a/10/853c723bcabc3e9825a079019055fc99e71b85c6bae600607a2b9d31d18d/ujson-5.12.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d79c6635ccffcbfc1d5c045874ba36b594589be81d50d43472570bb8de9c57", size = 57754, upload-time = "2026-03-11T22:18:20.874Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c6/6e024830d988f521f144ead641981c1f7a82c17ad1927c22de3242565f5c/ujson-5.12.0-cp312-cp312-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:7e07f6f644d2c44d53b7a320a084eef98063651912c1b9449b5f45fcbdc6ccd2", size = 59936, upload-time = "2026-03-11T22:18:21.924Z" }, + { url = "https://files.pythonhosted.org/packages/34/c9/c5f236af5abe06b720b40b88819d00d10182d2247b1664e487b3ed9229cf/ujson-5.12.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:085b6ce182cdd6657481c7c4003a417e0655c4f6e58b76f26ee18f0ae21db827", size = 57463, upload-time = "2026-03-11T22:18:22.924Z" }, + { url = "https://files.pythonhosted.org/packages/ae/04/41342d9ef68e793a87d84e4531a150c2b682f3bcedfe59a7a5e3f73e9213/ujson-5.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:16b4fe9c97dc605f5e1887a9e1224287291e35c56cbc379f8aa44b6b7bcfe2bb", size = 1037239, upload-time = "2026-03-11T22:18:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/d4/81/dc2b7617d5812670d4ff4a42f6dd77926430ee52df0dedb2aec7990b2034/ujson-5.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0d2e8db5ade3736a163906154ca686203acc7d1d30736cbf577c730d13653d84", size = 1196713, upload-time = "2026-03-11T22:18:25.391Z" }, + { url = "https://files.pythonhosted.org/packages/b6/9c/80acff0504f92459ed69e80a176286e32ca0147ac6a8252cd0659aad3227/ujson-5.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:93bc91fdadcf046da37a214eaa714574e7e9b1913568e93bb09527b2ceb7f759", size = 1089742, upload-time = "2026-03-11T22:18:26.738Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f1/0ef0eeab1db8493e1833c8b440fe32cf7538f7afa6e7f7c7e9f62cef464d/ujson-5.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:15d416440148f3e56b9b244fdaf8a09fcf5a72e4944b8e119f5bf60417a2bfc8", size = 56331, upload-time = "2026-03-11T22:18:31.539Z" }, + { url = "https://files.pythonhosted.org/packages/b0/2f/9159f6f399b3f572d20847a2b80d133e3a03c14712b0da4971a36879fb64/ujson-5.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0dd3676ea0837cd70ea1879765e9e9f6be063be0436de9b3ea4b775caf83654", size = 53910, upload-time = "2026-03-11T22:18:32.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/a9/f96376818d71495d1a4be19a0ab6acf0cc01dd8826553734c3d4dac685b2/ujson-5.12.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7bbf05c38debc90d1a195b11340cc85cb43ab3e753dc47558a3a84a38cbc72da", size = 57757, upload-time = "2026-03-11T22:18:33.866Z" }, + { url = "https://files.pythonhosted.org/packages/98/8d/dd4a151caac6fdcb77f024fbe7f09d465ebf347a628ed6dd581a0a7f6364/ujson-5.12.0-cp313-cp313-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:3c2f947e55d3c7cfe124dd4521ee481516f3007d13c6ad4bf6aeb722e190eb1b", size = 59940, upload-time = "2026-03-11T22:18:35.276Z" }, + { url = "https://files.pythonhosted.org/packages/c7/17/0d36c2fee0a8d8dc37b011ccd5bbdcfaff8b8ec2bcfc5be998661cdc935b/ujson-5.12.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ea6206043385343aff0b7da65cf73677f6f5e50de8f1c879e557f4298cac36a", size = 57465, upload-time = "2026-03-11T22:18:36.644Z" }, + { url = "https://files.pythonhosted.org/packages/8c/04/b0ee4a4b643a01ba398441da1e357480595edb37c6c94c508dbe0eb9eb60/ujson-5.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bb349dbba57c76eec25e5917e07f35aabaf0a33b9e67fc13d188002500106487", size = 1037236, upload-time = "2026-03-11T22:18:37.743Z" }, + { url = "https://files.pythonhosted.org/packages/2d/08/0e7780d0bbb48fe57ded91f550144bcc99c03b5360bf2886dd0dae0ea8f5/ujson-5.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:937794042342006f707837f38d721426b11b0774d327a2a45c0bd389eb750a87", size = 1196717, upload-time = "2026-03-11T22:18:39.101Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4c/e0e34107715bb4dd2d4dcc1ce244d2f074638837adf38aff85a37506efe4/ujson-5.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6ad57654570464eb1b040b5c353dee442608e06cff9102b8fcb105565a44c9ed", size = 1089748, upload-time = "2026-03-11T22:18:40.473Z" }, + { url = "https://files.pythonhosted.org/packages/10/bd/9a8d693254bada62bfea75a507e014afcfdb6b9d047b6f8dd134bfefaf67/ujson-5.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85833bca01aa5cae326ac759276dc175c5fa3f7b3733b7d543cf27f2df12d1ef", size = 56499, upload-time = "2026-03-11T22:18:45.431Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2d/285a83df8176e18dcd675d1a4cff8f7620f003f30903ea43929406e98986/ujson-5.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d22cad98c2a10bbf6aa083a8980db6ed90d4285a841c4de892890c2b28286ef9", size = 53998, upload-time = "2026-03-11T22:18:47.184Z" }, + { url = "https://files.pythonhosted.org/packages/bf/8b/e2f09e16dabfa91f6a84555df34a4329fa7621e92ed054d170b9054b9bb2/ujson-5.12.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99cc80facad240b0c2fb5a633044420878aac87a8e7c348b9486450cba93f27c", size = 57783, upload-time = "2026-03-11T22:18:48.271Z" }, + { url = "https://files.pythonhosted.org/packages/68/fb/ba1d06f3658a0c36d0ab3869ec3914f202bad0a9bde92654e41516c7bb13/ujson-5.12.0-cp314-cp314-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:d1831c07bd4dce53c4b666fa846c7eba4b7c414f2e641a4585b7f50b72f502dc", size = 60011, upload-time = "2026-03-11T22:18:49.284Z" }, + { url = "https://files.pythonhosted.org/packages/64/2b/3e322bf82d926d9857206cd5820438d78392d1f523dacecb8bd899952f73/ujson-5.12.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e00cec383eab2406c9e006bd4edb55d284e94bb943fda558326048178d26961", size = 57465, upload-time = "2026-03-11T22:18:50.584Z" }, + { url = "https://files.pythonhosted.org/packages/e9/fd/af72d69603f9885e5136509a529a4f6d88bf652b457263ff96aefcd3ab7d/ujson-5.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f19b3af31d02a2e79c5f9a6deaab0fb3c116456aeb9277d11720ad433de6dfc6", size = 1037275, upload-time = "2026-03-11T22:18:51.998Z" }, + { url = "https://files.pythonhosted.org/packages/9c/a7/a2411ec81aef7872578e56304c3e41b3a544a9809e95c8e1df46923fc40b/ujson-5.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bacbd3c69862478cbe1c7ed4325caedec580d8acf31b8ee1b9a1e02a56295cad", size = 1196758, upload-time = "2026-03-11T22:18:53.548Z" }, + { url = "https://files.pythonhosted.org/packages/ed/85/aa18ae175dd03a118555aa14304d4f466f9db61b924c97c6f84388ecacb1/ujson-5.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94c5f1621cbcab83c03be46441f090b68b9f307b6c7ec44d4e3f6d5997383df4", size = 1089760, upload-time = "2026-03-11T22:18:55.336Z" }, + { url = "https://files.pythonhosted.org/packages/c3/71/9b4dacb177d3509077e50497222d39eec04c8b41edb1471efc764d645237/ujson-5.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ddb08b3c2f9213df1f2e3eb2fbea4963d80ec0f8de21f0b59898e34f3b3d96d", size = 56845, upload-time = "2026-03-11T22:18:59.629Z" }, + { url = "https://files.pythonhosted.org/packages/24/c2/8abffa3be1f3d605c4a62445fab232b3e7681512ce941c6b23014f404d36/ujson-5.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0a3ae28f0b209be5af50b54ca3e2123a3de3a57d87b75f1e5aa3d7961e041983", size = 54463, upload-time = "2026-03-11T22:19:00.697Z" }, + { url = "https://files.pythonhosted.org/packages/db/2e/60114a35d1d6796eb428f7affcba00a921831ff604a37d9142c3d8bbe5c5/ujson-5.12.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30ad4359413c8821cc7b3707f7ca38aa8bc852ba3b9c5a759ee2d7740157315", size = 58689, upload-time = "2026-03-11T22:19:01.739Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ad/010925c2116c21ce119f9c2ff18d01f48a19ade3ff4c5795da03ce5829fc/ujson-5.12.0-cp314-cp314t-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:02f93da7a4115e24f886b04fd56df1ee8741c2ce4ea491b7ab3152f744ad8f8e", size = 60618, upload-time = "2026-03-11T22:19:03.101Z" }, + { url = "https://files.pythonhosted.org/packages/9b/74/db7f638bf20282b1dccf454386cbd483faaaed3cdbb9cb27e06f74bb109e/ujson-5.12.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3ff4ede90ed771140caa7e1890de17431763a483c54b3c1f88bd30f0cc1affc0", size = 58151, upload-time = "2026-03-11T22:19:04.175Z" }, + { url = "https://files.pythonhosted.org/packages/9c/7e/3ebaecfa70a2e8ce623db8e21bd5cb05d42a5ef943bcbb3309d71b5de68d/ujson-5.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bf9cc97f05048ac8f3e02cd58f0fe62b901453c24345bfde287f4305dcc31c", size = 1038117, upload-time = "2026-03-11T22:19:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/2e/aa/e073eda7f0036c2973b28db7bb99faba17a932e7b52d801f9bb3e726271f/ujson-5.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2324d9a0502317ffc35d38e153c1b2fa9610ae03775c9d0f8d0cca7b8572b04e", size = 1197434, upload-time = "2026-03-11T22:19:06.92Z" }, + { url = "https://files.pythonhosted.org/packages/1c/01/b9a13f058fdd50c746b192c4447ca8d6352e696dcda912ccee10f032ff85/ujson-5.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:50524f4f6a1c839714dbaff5386a1afb245d2d5ec8213a01fbc99cea7307811e", size = 1090401, upload-time = "2026-03-11T22:19:08.383Z" }, + { url = "https://files.pythonhosted.org/packages/95/3c/5ee154d505d1aad2debc4ba38b1a60ae1949b26cdb5fa070e85e320d6b64/ujson-5.12.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:bf85a00ac3b56a1e7a19c5be7b02b5180a0895ac4d3c234d717a55e86960691c", size = 54494, upload-time = "2026-03-11T22:19:13.035Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b3/9496ec399ec921e434a93b340bd5052999030b7ac364be4cbe5365ac6b20/ujson-5.12.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:64df53eef4ac857eb5816a56e2885ccf0d7dff6333c94065c93b39c51063e01d", size = 57999, upload-time = "2026-03-11T22:19:14.385Z" }, + { url = "https://files.pythonhosted.org/packages/0e/da/e9ae98133336e7c0d50b43626c3f2327937cecfa354d844e02ac17379ed1/ujson-5.12.0-graalpy312-graalpy250_312_native-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c0aed6a4439994c9666fb8a5b6c4eac94d4ef6ddc95f9b806a599ef83547e3b", size = 54518, upload-time = "2026-03-11T22:19:15.4Z" }, + { url = "https://files.pythonhosted.org/packages/58/10/978d89dded6bb1558cd46ba78f4351198bd2346db8a8ee1a94119022ce40/ujson-5.12.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efae5df7a8cc8bdb1037b0f786b044ce281081441df5418c3a0f0e1f86fe7bb3", size = 55736, upload-time = "2026-03-11T22:19:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/19/fa/f4a957dddb99bd68c8be91928c0b6fefa7aa8aafc92c93f5d1e8b32f6702/ujson-5.12.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:871c0e5102e47995b0e37e8df7819a894a6c3da0d097545cd1f9f1f7d7079927", size = 52145, upload-time = "2026-03-11T22:19:18.566Z" }, + { url = "https://files.pythonhosted.org/packages/55/6e/50b5cf612de1ca06c7effdc5a5d7e815774dee85a5858f1882c425553b82/ujson-5.12.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:56ba3f7abbd6b0bb282a544dc38406d1a188d8bb9164f49fdb9c2fee62cb29da", size = 49577, upload-time = "2026-03-11T22:19:19.627Z" }, + { url = "https://files.pythonhosted.org/packages/6e/24/b6713fa9897774502cd4c2d6955bb4933349f7d84c3aa805531c382a4209/ujson-5.12.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c5a52987a990eb1bae55f9000994f1afdb0326c154fb089992f839ab3c30688", size = 50807, upload-time = "2026-03-11T22:19:20.778Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b6/c0e0f7901180ef80d16f3a4bccb5dc8b01515a717336a62928963a07b80b/ujson-5.12.0-pp311-pypy311_pp73-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:adf28d13a33f9d750fe7a78fb481cac298fa257d8863d8727b2ea4455ea41235", size = 56972, upload-time = "2026-03-11T22:19:21.84Z" }, + { url = "https://files.pythonhosted.org/packages/02/a9/05d91b4295ea7239151eb08cf240e5a2ba969012fda50bc27bcb1ea9cd71/ujson-5.12.0-pp311-pypy311_pp73-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51acc750ec7a2df786cdc868fb16fa04abd6269a01d58cf59bafc57978773d8e", size = 52045, upload-time = "2026-03-11T22:19:22.879Z" }, +] + +[[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 = "virtualenv" +version = "20.26.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/40/abc5a766da6b0b2457f819feab8e9203cbeae29327bd241359f866a3da9d/virtualenv-20.26.6.tar.gz", hash = "sha256:280aede09a2a5c317e409a00102e7077c6432c5a38f0ef938e643805a7ad2c48", size = 9372482, upload-time = "2024-09-27T16:28:57.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/90/57b8ac0c8a231545adc7698c64c5a36fa7cd8e376c691b9bde877269f2eb/virtualenv-20.26.6-py3-none-any.whl", hash = "sha256:7345cc5b25405607a624d8418154577459c3e0277f5466dd79c49d5e492995f2", size = 5999862, upload-time = "2024-09-27T16:28:54.798Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" }, + { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" }, + { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" }, + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +]