diff --git a/CODEOWNERS b/CODEOWNERS index cc3b8c2d0..5eb7f7753 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -30,3 +30,4 @@ # charmlibs.interfaces packages (alphabetical) /interfaces/tls-certificates/ @canonical/tls +/interfaces/haproxy_spoe_auth/ @canonical/platform-engineering diff --git a/interfaces/haproxy_spoe_auth/CHANGELOG.md b/interfaces/haproxy_spoe_auth/CHANGELOG.md new file mode 100644 index 000000000..e69de29bb diff --git a/interfaces/haproxy_spoe_auth/README.md b/interfaces/haproxy_spoe_auth/README.md new file mode 100644 index 000000000..f609700d7 --- /dev/null +++ b/interfaces/haproxy_spoe_auth/README.md @@ -0,0 +1,11 @@ +# charmlibs.interfaces.haproxy_spoe_auth + +The `haproxy_spoe_auth` interface library. + +To install, add `charmlibs-interfaces-haproxy-spoe-auth` to your Python dependencies. Then in your Python code, import as: + +```py +from charmlibs.interfaces import haproxy_spoe_auth +``` + +See the [reference documentation](https://documentation.ubuntu.com/charmlibs/reference/charmlibs/interfaces/haproxy_spoe_auth) for more. diff --git a/interfaces/haproxy_spoe_auth/interface/v0/README.md b/interfaces/haproxy_spoe_auth/interface/v0/README.md new file mode 100644 index 000000000..d1ff352f5 --- /dev/null +++ b/interfaces/haproxy_spoe_auth/interface/v0/README.md @@ -0,0 +1,56 @@ +# `spoe-auth/v0` + +## Usage + +This relation interface describes the expected behavior of any charm that can work with the haproxy charm to provide authentication capabilities through SPOE ( Stream Process Offloading Engine ). + +## Direction + +SPOE allows haproxy to be extended with middlewares. SPOA are agents that talk to haproxy using the Stream Process Offloading Protocol ( SPOP ). + +Providers are agent charms that validates incoming requests, communicates to Haproxy the full redirect URL to the IDP in case of unauthenticated requests, receives the OIDC callback and finally issues a redirect to the original destination to set the authentication cookie on the client browser if the request is authenticated. + +The haproxy-operator charm is the only requirer charm as of now. + +## Behavior +### Provider + +- Is expected to expose a TCP port receiving SPOE messages ( through SPOP ). This port needs to be communicated to the requirer ( haproxy ). +- Is expected to reply to the SPOE messages with the appropriate set-var Actions, mainly idicating whether the request is authenticated +and if not, what's the IDP redirect URL that haproxy need to use as the response. +- Is expected to expose an HTTP port receiving the OIDC callback requests. The hostname and path prefix used to route requests to +this port needs to be communicated to the requirer ( haproxy ) + +### Requirer ( haproxy ) + +- Is expected to use the information available in the relation data to perform the corresponding actions. Specifically: + - Update the haproxy configuration to define SPOE message parameters, define the SPOP/redirect/callback backends and add routing rules accordingly + + +## Relation Data + +### Provider + +The provider exposes via its application databag informations about the SPOP and the OIDC callback endpoints via the `spop_port`, and `oidc_callback_*` attributes respectively. The provider also communicates the name of the variables for important flags such as "Is the user authenticated" (`var_authenticated`) or "The full URL to issue a redirect to the IDP" (`var_redirect_url`). The provider also exposes the name of the SPOE message, the event that should trigger the SPOE message and the name of the cookie to include in the SPOE message via the `message_name`, `event` and `cookie_name` attribute respectively. + + +#### Example +```yaml +unit_data: + unit/0: + address: 10.0.0.1 + +application_data: + spop_port: 12345 + event: on-frontend-http-request + message_name: try-auth-oidc + var_authenticated: sess.auth.is_authenticated + var_redirect_url: sess.auth.redirect_url + cookie_name: sessioncookie + oidc_callback_port: 5000 + oidc_callback_path: /oauth2/callback + hostname: auth.haproxy.internal +``` + +### Requirer +No data is communicated from the requirer side. \ No newline at end of file diff --git a/interfaces/haproxy_spoe_auth/interface/v0/interface.yaml b/interfaces/haproxy_spoe_auth/interface/v0/interface.yaml new file mode 100644 index 000000000..a88400081 --- /dev/null +++ b/interfaces/haproxy_spoe_auth/interface/v0/interface.yaml @@ -0,0 +1,7 @@ +providers: + - name: haproxy-spoe-auth + url: https://www.github.com/canonical/haproxy-operator/haproxy_spoe_auth_operator + +requirers: + - name: haproxy + url: https://www.github.com/canonical/haproxy-operator diff --git a/interfaces/haproxy_spoe_auth/pyproject.toml b/interfaces/haproxy_spoe_auth/pyproject.toml new file mode 100644 index 000000000..fe2c0305f --- /dev/null +++ b/interfaces/haproxy_spoe_auth/pyproject.toml @@ -0,0 +1,73 @@ +[project] +name = "charmlibs-interfaces-haproxy-spoe-auth" +description = "The charmlibs.interfaces.haproxy_spoe_auth package." +readme = "README.md" +requires-python = ">=3.12" +authors = [ + {name="The Platform Engineering team at Canonical"}, +] +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: Apache Software License", + "Intended Audience :: Developers", + "Operating System :: POSIX :: Linux", + "Development Status :: 5 - Production/Stable", +] +dynamic = ["version"] +dependencies = [ + "ops>=3.3.1", + # "ops", + "pydantic>=2.12.4", +] + +[dependency-groups] +lint = [ # installed for `just lint interfaces/haproxy_spoe_auth` (unit, functional, and integration are also installed) + # "typing_extensions", +] +unit = [ # installed for `just unit interfaces/haproxy_spoe_auth` + "ops[testing]", +] +functional = [ # installed for `just functional interfaces/haproxy_spoe_auth` +] +integration = [ # installed for `just integration interfaces/haproxy_spoe_auth` + "jubilant", +] + +[project.urls] +"Repository" = "https://github.com/canonical/charmlibs" +"Issues" = "https://github.com/canonical/charmlibs/issues" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/charmlibs"] + +[tool.hatch.version] +path = "src/charmlibs/interfaces/haproxy_spoe_auth/_version.py" + +[tool.ruff] +extend = "../../pyproject.toml" +src = ["src", "tests/unit", "tests/functional", "tests/integration"] # correctly sort local imports in tests + +[tool.ruff.lint.extend-per-file-ignores] +# add additional per-file-ignores here to avoid overriding repo-level config +"tests/**/*" = [ + # "E501", # line too long +] + +[tool.pyright] +extends = "../../pyproject.toml" +include = ["src", "tests"] +pythonVersion = "3.12" # check no python > 3.12 features are used + +[tool.charmlibs.functional] +ubuntu = [] # ubuntu versions to run functional tests with, e.g. "24.04" (defaults to just "latest") +pebble = [] # pebble versions to run functional tests with, e.g. "v1.0.0", "master" (defaults to no pebble versions) +sudo = false # whether to run functional tests with sudo (defaults to false) + +[tool.charmlibs.integration] +# tags to run integration tests with (defaults to running once with no tag, i.e. tags = ['']) +# Available in CI in tests/integration/pack.sh and integration tests as CHARMLIBS_TAG +tags = [] # Not used by the pack.sh and integration tests generated by the template diff --git a/interfaces/haproxy_spoe_auth/src/charmlibs/interfaces/haproxy_spoe_auth/__init__.py b/interfaces/haproxy_spoe_auth/src/charmlibs/interfaces/haproxy_spoe_auth/__init__.py new file mode 100644 index 000000000..7bc09e9ac --- /dev/null +++ b/interfaces/haproxy_spoe_auth/src/charmlibs/interfaces/haproxy_spoe_auth/__init__.py @@ -0,0 +1,41 @@ +# Copyright 2025 Canonical Ltd. +# +# 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. + +"""The charmlibs.interfaces.haproxy_spoe_auth package.""" + +from ._spoe_auth import ( + HaproxyEvent, + SpoeAuthAvailableEvent, + SpoeAuthInvalidRelationDataError, + SpoeAuthProvider, + SpoeAuthProviderAppData, + SpoeAuthProviderUnitData, + SpoeAuthRemovedEvent, + SpoeAuthRequirer, +) +from ._version import __version__ as __version__ + +# only the names listed in __all__ are imported when executing: +# from charmlibs.haproxy_spoe_auth import * +__all__ = [ + 'HaproxyEvent', + 'SpoeAuthAvailableEvent', + 'SpoeAuthInvalidRelationDataError', + 'SpoeAuthProvider', + 'SpoeAuthProvider', + 'SpoeAuthProviderAppData', + 'SpoeAuthProviderUnitData', + 'SpoeAuthRemovedEvent', + 'SpoeAuthRequirer', +] diff --git a/interfaces/haproxy_spoe_auth/src/charmlibs/interfaces/haproxy_spoe_auth/_spoe_auth.py b/interfaces/haproxy_spoe_auth/src/charmlibs/interfaces/haproxy_spoe_auth/_spoe_auth.py new file mode 100644 index 000000000..2125c83c9 --- /dev/null +++ b/interfaces/haproxy_spoe_auth/src/charmlibs/interfaces/haproxy_spoe_auth/_spoe_auth.py @@ -0,0 +1,521 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +# pylint: disable=duplicate-code +"""Source code for the spoe-auth interface library. + +## Using the library as the Provider + +The provider charm should expose the interface as shown below: + +```yaml +provides: + spoe-auth: + interface: spoe-auth +``` + +Then, add `charmlibs-interfaces-haproxy-spoe-auth` to your Python dependencies. Example using uv: +```shell +uv add charmlibs-interfaces-haproxy-spoe-auth +``` + +Then in your Python code, import the Provider class and confgure the relation as shown below: + +```python +from charmlibs.interfaces.haproxy_spoe_auth import SpoeAuthProvider, HaproxyEvent + +class SpoeAuthCharm(CharmBase): + def __init__(self, *args): + super().__init__(*args) + self.spoe_auth = SpoeAuthProvider(self, relation_name="spoe-auth") + + self.framework.observe( + self.on.config_changed, self._on_config_changed + ) + + def _on_config_changed(self, event): + # Publish the SPOE auth configuration + self.spoe_auth.provide_spoe_auth_requirements( + spop_port=8081, + oidc_callback_port=5000, + event=HaproxyEvent.ON_HTTP_REQUEST, + var_authenticated="var.sess.is_authenticated", + var_redirect_url="var.sess.redirect_url", + cookie_name="auth_session", + hostname="auth.example.com", + oidc_callback_path="/oauth2/callback", + ) +``` +""" + +import json +import logging +import re +from collections.abc import MutableMapping +from enum import StrEnum +from typing import Annotated, cast + +from ops import CharmBase, RelationBrokenEvent +from ops.charm import CharmEvents +from ops.framework import EventBase, EventSource, Object +from ops.model import Relation +from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, IPvAnyAddress, ValidationError + +logger = logging.getLogger(__name__) +SPOE_AUTH_DEFAULT_RELATION_NAME = 'spoe-auth' +HAPROXY_CONFIG_INVALID_CHARACTERS = '\n\t#\\\'"\r$ ' +# RFC-1034 and RFC-2181 compliance REGEX for validating FQDNs +HOSTNAME_REGEX = ( + r'^(?=.{1,253})(?!.*--.*)(?:(?!-)(?![0-9])[a-zA-Z0-9-]' + r'{1,63}(? str: + """Validate if value contains invalid haproxy config characters. + + Args: + value: The value to validate. + + Raises: + ValueError: When value contains invalid characters. + + Returns: + The validated value. + """ + if [char for char in value if char in HAPROXY_CONFIG_INVALID_CHARACTERS]: + raise ValueError(f'Relation data contains invalid character(s) {value}') + return value + + +def validate_hostname(value: str) -> str: + """Validate if value is a valid hostname per RFC 1123. + + Args: + value: The value to validate. + + Raises: + ValueError: When value is not a valid hostname. + + Returns: + The validated value. + """ + if not re.match(HOSTNAME_REGEX, value): + raise ValueError(f'Invalid hostname: {value}') + return value + + +VALIDSTR = Annotated[str, BeforeValidator(value_contains_invalid_characters)] + + +class DataValidationError(Exception): + """Raised when data validation fails.""" + + +class SpoeAuthInvalidRelationDataError(Exception): + """Raised when data validation of the spoe-auth relation fails.""" + + +class _DatabagModel(BaseModel): + """Base databag model. + + Attrs: + model_config: pydantic model configuration. + """ + + model_config = ConfigDict( + # tolerate additional keys in databag + extra='ignore', + # Allow instantiating this class by field name (instead of forcing alias). + populate_by_name=True, + # Custom config key: whether to nest the whole datastructure (as json) + # under a field or spread it out at the toplevel. + _NEST_UNDER=None, + ) # type: ignore + """Pydantic config.""" + + @classmethod + def load(cls, databag: MutableMapping[str, str]) -> '_DatabagModel': + """Load this model from a Juju json databag. + + Args: + databag: Databag content. + + Raises: + DataValidationError: When model validation failed. + + Returns: + _DatabagModel: The validated model. + """ + nest_under = cls.model_config.get('_NEST_UNDER') + if nest_under: + return cls.model_validate(json.loads(databag[nest_under])) + + try: + data = { + k: json.loads(v) + for k, v in databag.items() + # Don't attempt to parse model-external values + if k in {(f.alias or n) for n, f in cls.model_fields.items()} + } + except json.JSONDecodeError as e: + msg = f'invalid databag contents: expecting json. {databag}' + logger.error(msg) + raise DataValidationError(msg) from e + + try: + return cls.model_validate_json(json.dumps(data)) + except ValidationError as e: + msg = f'failed to validate databag: {databag}' + logger.error(msg) + raise DataValidationError(msg) from e + + def dump( + self, databag: MutableMapping[str, str] | None = None, clear: bool = True + ) -> MutableMapping[str, str] | None: + """Write the contents of this model to Juju databag. + + Args: + databag: The databag to write to. + clear: Whether to clear the databag before writing. + + Returns: + MutableMapping: The databag. + """ + if clear and databag: + databag.clear() + + if databag is None: + databag = {} + nest_under = self.model_config.get('_NEST_UNDER') + if nest_under: + databag[nest_under] = self.model_dump_json( + by_alias=True, + ) + return databag + + dct = self.model_dump(mode='json', by_alias=True) + databag.update({k: json.dumps(v) for k, v in dct.items()}) + return databag + + +class HaproxyEvent(StrEnum): + """Enumeration of HAProxy SPOE events.""" + + ON_FRONTEND_HTTP_REQUEST = 'on-frontend-http-request' + + +class SpoeAuthProviderAppData(_DatabagModel): + """spoe-auth provider application data model.""" + + spop_port: int = Field( + description='The port on the agent listening for SPOP.', + gt=0, + le=65525, + ) + oidc_callback_port: int = Field( + description='The port on the agent handling OIDC callbacks.', + gt=0, + le=65525, + ) + event: HaproxyEvent = Field( + description='The event that triggers SPOE messages (e.g., on-http-request).', + ) + message_name: str = Field( + description='The name of the SPOE message that the provider expects.' + ) + var_authenticated: VALIDSTR = Field( + description='Name of the variable set by the SPOE agent for auth status.', + ) + var_redirect_url: VALIDSTR = Field( + description='Name of the variable set by the SPOE agent for IDP redirect URL.', + ) + cookie_name: VALIDSTR = Field( + description='Name of the authentication cookie used by the SPOE agent.', + ) + oidc_callback_path: VALIDSTR = Field( + description='Path for OIDC callback.', default='/oauth2/callback' + ) + hostname: Annotated[str, BeforeValidator(validate_hostname)] = Field( + description='The hostname HAProxy should route OIDC callbacks to.', + ) + + +class SpoeAuthProviderUnitData(_DatabagModel): + """spoe-auth provider unit data model.""" + + address: IPvAnyAddress = Field(description='IP address of the unit.') + + +class SpoeAuthProvider(Object): + """SPOE auth interface provider implementation.""" + + def __init__( + self, charm: CharmBase, relation_name: str = SPOE_AUTH_DEFAULT_RELATION_NAME + ) -> None: + """Initialize the SpoeAuthProvider. + + Args: + charm: The charm that is instantiating the library. + relation_name: The name of the relation to bind to. + """ + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + + @property + def relations(self) -> list[Relation]: + """The list of Relation instances associated with this relation_name. + + Returns: + list[Relation]: The list of relations. + """ + return list(self.charm.model.relations[self.relation_name]) + + # pylint: disable=too-many-arguments,too-many-positional-arguments + def provide_spoe_auth_requirements( + self, + relation: Relation, + spop_port: int, + oidc_callback_port: int, + event: HaproxyEvent, + message_name: str, + var_authenticated: str, + var_redirect_url: str, + cookie_name: str, + hostname: str, + oidc_callback_path: str = '/oauth2/callback', + unit_address: str | None = None, + ) -> None: + """Set the SPOE auth configuration in the application databag. + + Args: + relation: The relation instance to set data on. + spop_port: The port on the agent listening for SPOP. + oidc_callback_port: The port on the agent handling OIDC callbacks. + event: The event that triggers SPOE messages. + message_name: The name of the SPOE message that the provider expects. + var_authenticated: Name of the variable for auth status. + var_redirect_url: Name of the variable for IDP redirect URL. + cookie_name: Name of the authentication cookie. + hostname: The hostname HAProxy should route OIDC callbacks to. + oidc_callback_path: Path for OIDC callback. + unit_address: The address of the unit. + + Raises: + DataValidationError: When validation of application data fails. + """ + if not self.charm.unit.is_leader(): + logger.warning('Only the leader unit can set the SPOE auth configuration.') + return + + try: + application_data = SpoeAuthProviderAppData( + spop_port=spop_port, + oidc_callback_port=oidc_callback_port, + event=event, + message_name=message_name, + var_authenticated=var_authenticated, + var_redirect_url=var_redirect_url, + cookie_name=cookie_name, + hostname=hostname, + oidc_callback_path=oidc_callback_path, + ) + unit_data = self._prepare_unit_data(unit_address) + except ValidationError as exc: + logger.error('Validation error when preparing provider relation data.') + raise DataValidationError( + 'Validation error when preparing provider relation data.' + ) from exc + + if self.charm.unit.is_leader(): + application_data.dump(relation.data[self.charm.app], clear=True) + unit_data.dump(relation.data[self.charm.unit], clear=True) + + def _prepare_unit_data(self, unit_address: str | None) -> SpoeAuthProviderUnitData: + """Prepare and validate unit data. + + Raises: + DataValidationError: When no address or unit IP is available. + + Returns: + RequirerUnitData: The validated unit data model. + """ + if not unit_address: + network_binding = self.charm.model.get_binding(self.relation_name) + if ( + network_binding is not None + and (bind_address := network_binding.network.bind_address) is not None + ): + unit_address = str(bind_address) + else: + logger.error('No unit IP available.') + raise DataValidationError('No unit IP available.') + return SpoeAuthProviderUnitData(address=cast('IPvAnyAddress', unit_address)) + + +class SpoeAuthAvailableEvent(EventBase): + """SpoeAuthAvailableEvent custom event.""" + + +class SpoeAuthRemovedEvent(EventBase): + """SpoeAuthRemovedEvent custom event.""" + + +class SpoeAuthRequirerEvents(CharmEvents): + """List of events that the SPOE auth requirer charm can leverage. + + Attributes: + available: Emitted when provider configuration is available. + removed: Emitted when the provider relation is broken. + """ + + available = EventSource(SpoeAuthAvailableEvent) + removed = EventSource(SpoeAuthRemovedEvent) + + +class SpoeAuthRequirer(Object): + """SPOE auth interface requirer implementation.""" + + # Ignore this for pylance + on = SpoeAuthRequirerEvents() # type: ignore + + def __init__( + self, charm: CharmBase, relation_name: str = SPOE_AUTH_DEFAULT_RELATION_NAME + ) -> None: + """Initialize the SpoeAuthRequirer. + + Args: + charm: The charm that is instantiating the library. + relation_name: The name of the relation to bind to. + """ + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + + self.framework.observe(self.charm.on[self.relation_name].relation_created, self._configure) + self.framework.observe(self.charm.on[self.relation_name].relation_changed, self._configure) + self.framework.observe( + self.charm.on[self.relation_name].relation_broken, self._on_relation_broken + ) + + @property + def relation(self) -> Relation | None: + """The relation instance associated with this relation_name. + + Returns: + Optional[Relation]: The relation instance, or None if not available. + """ + relations = self.charm.model.relations[self.relation_name] + return relations[0] if relations else None + + def _configure(self, _: EventBase) -> None: + """Handle relation changed events.""" + if self.is_available(): + self.on.available.emit() + + def _on_relation_broken(self, _: RelationBrokenEvent) -> None: + """Handle relation broken events.""" + self.on.removed.emit() + + def is_available(self) -> bool: + """Check if the SPOE auth configuration is available and valid. + + Returns: + bool: True if configuration is available and valid, False otherwise. + """ + if not self.relation: + return False + + if not self.relation.app: + return False + + try: + databag = self.relation.data[self.relation.app] + if not databag: + return False + SpoeAuthProviderAppData.load(databag) + return True + except (DataValidationError, KeyError): + return False + + def get_data(self) -> SpoeAuthProviderAppData | None: + """Get the SPOE auth configuration from the provider. + + Returns: + Optional[SpoeAuthProviderAppData]: The SPOE auth configuration, + or None if not available. + + Raises: + SpoeAuthInvalidRelationDataError: When configuration data is invalid. + """ + if not self.relation: + return None + + if not self.relation.app: + return None + + try: + databag = self.relation.data[self.relation.app] + if not databag: + return None + return SpoeAuthProviderAppData.load(databag) # type: ignore + except DataValidationError as exc: + logger.error( + 'spoe-auth data validation failed for relation %s: %s', + self.relation, + str(exc), + ) + raise SpoeAuthInvalidRelationDataError( + f'spoe-auth data validation failed for relation: {self.relation}' + ) from exc + + def get_provider_unit_data(self, relation: Relation) -> list[SpoeAuthProviderUnitData]: + """Fetch and validate the requirer's units data. + + Args: + relation: The relation to fetch unit data from. + + Raises: + DataValidationError: When unit data validation fails. + + Returns: + list[SpoeAuthProviderUnitData]: List of validated unit data from the provider. + """ + requirer_units_data: list[SpoeAuthProviderUnitData] = [] + + for unit in relation.units: + databag = relation.data.get(unit) + if not databag: + logger.error( + 'Requirer unit data does not exist even though the unit is still present.' + ) + continue + try: + data = cast('SpoeAuthProviderUnitData', SpoeAuthProviderUnitData.load(databag)) + requirer_units_data.append(data) + except DataValidationError: + logger.error('Invalid requirer application data for %s', unit) + raise + return requirer_units_data + + def get_provider_application_data(self, relation: Relation) -> SpoeAuthProviderAppData: + """Fetch and validate the requirer's application databag. + + Args: + relation: The relation to fetch application data from. + + Raises: + DataValidationError: When requirer application data validation fails. + + Returns: + RequirerApplicationData: Validated application data from the requirer. + """ + try: + return cast( + 'SpoeAuthProviderAppData', + SpoeAuthProviderAppData.load(relation.data[relation.app]), + ) + except DataValidationError: + logger.error('Invalid requirer application data for %s', relation.app.name) + raise diff --git a/interfaces/haproxy_spoe_auth/src/charmlibs/interfaces/haproxy_spoe_auth/_version.py b/interfaces/haproxy_spoe_auth/src/charmlibs/interfaces/haproxy_spoe_auth/_version.py new file mode 100644 index 000000000..a6f32dc20 --- /dev/null +++ b/interfaces/haproxy_spoe_auth/src/charmlibs/interfaces/haproxy_spoe_auth/_version.py @@ -0,0 +1,15 @@ +# Copyright 2025 Canonical Ltd. +# +# 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__ = '0.0.1' diff --git a/interfaces/haproxy_spoe_auth/src/charmlibs/interfaces/haproxy_spoe_auth/py.typed b/interfaces/haproxy_spoe_auth/src/charmlibs/interfaces/haproxy_spoe_auth/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/interfaces/haproxy_spoe_auth/tests/unit/conftest.py b/interfaces/haproxy_spoe_auth/tests/unit/conftest.py new file mode 100644 index 000000000..ee259deed --- /dev/null +++ b/interfaces/haproxy_spoe_auth/tests/unit/conftest.py @@ -0,0 +1,15 @@ +# Copyright 2025 Canonical Ltd. +# +# 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. + +"""Fixtures for unit tests, typically mocking out parts of the external system.""" diff --git a/interfaces/haproxy_spoe_auth/tests/unit/test_haproxy_spoe_auth_lib.py b/interfaces/haproxy_spoe_auth/tests/unit/test_haproxy_spoe_auth_lib.py new file mode 100644 index 000000000..5c75b283d --- /dev/null +++ b/interfaces/haproxy_spoe_auth/tests/unit/test_haproxy_spoe_auth_lib.py @@ -0,0 +1,406 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Unit tests for SPOE auth interface library.""" + +import json +import re +from typing import Any, cast + +import pytest +from pydantic import IPvAnyAddress, ValidationError + +from charmlibs.interfaces.haproxy_spoe_auth import ( + HaproxyEvent, + SpoeAuthProviderAppData, + SpoeAuthProviderUnitData, +) +from charmlibs.interfaces.haproxy_spoe_auth._spoe_auth import HOSTNAME_REGEX, DataValidationError + +PLACEHOLDER_ADDRESS = '10.0.0.1' +PLACEHOLDER_SPOP_PORT = 8081 +PLACEHOLDER_OIDC_CALLBACK_PORT = 5000 +PLACEHOLDER_VAR_AUTHENTICATED = 'var.sess.is_authenticated' +PLACEHOLDER_VAR_REDIRECT_URL = 'var.sess.redirect_url' +PLACEHOLDER_COOKIE_NAME = 'auth_session' +PLACEHOLDER_hostname = 'auth.example.com' +PLACEHOLDER_OIDC_CALLBACK_PATH = '/oauth2/callback' + + +@pytest.fixture(name='mock_provider_app_data_dict') +def mock_provider_app_data_dict_fixture() -> dict[str, Any]: + """Create mock provider application data dictionary.""" + return { + 'spop_port': PLACEHOLDER_SPOP_PORT, + 'oidc_callback_port': PLACEHOLDER_OIDC_CALLBACK_PORT, + 'event': 'on-frontend-http-request', + 'message_name': 'try-auth-oidc', + 'var_authenticated': PLACEHOLDER_VAR_AUTHENTICATED, + 'var_redirect_url': PLACEHOLDER_VAR_REDIRECT_URL, + 'cookie_name': PLACEHOLDER_COOKIE_NAME, + 'oidc_callback_path': PLACEHOLDER_OIDC_CALLBACK_PATH, + 'hostname': PLACEHOLDER_hostname, + } + + +@pytest.fixture(name='mock_provider_unit_data_dict') +def mock_provider_unit_data_dict_fixture() -> dict[str, str]: + """Create mock provider unit data dictionary.""" + return {'address': PLACEHOLDER_ADDRESS} + + +def test_spoe_auth_provider_app_data_validation(): + """ + arrange: Create a SpoeAuthProviderAppData model with valid data. + act: Validate the model. + assert: Model validation passes. + """ + data = SpoeAuthProviderAppData( + spop_port=PLACEHOLDER_SPOP_PORT, + oidc_callback_port=PLACEHOLDER_OIDC_CALLBACK_PORT, + event=HaproxyEvent.ON_FRONTEND_HTTP_REQUEST, + message_name='try-auth-oidc', + var_authenticated=PLACEHOLDER_VAR_AUTHENTICATED, + var_redirect_url=PLACEHOLDER_VAR_REDIRECT_URL, + cookie_name=PLACEHOLDER_COOKIE_NAME, + hostname=PLACEHOLDER_hostname, + oidc_callback_path=PLACEHOLDER_OIDC_CALLBACK_PATH, + ) + + assert data.spop_port == PLACEHOLDER_SPOP_PORT + assert data.oidc_callback_port == PLACEHOLDER_OIDC_CALLBACK_PORT + assert data.event == HaproxyEvent.ON_FRONTEND_HTTP_REQUEST + assert data.var_authenticated == PLACEHOLDER_VAR_AUTHENTICATED + assert data.var_redirect_url == PLACEHOLDER_VAR_REDIRECT_URL + assert data.cookie_name == PLACEHOLDER_COOKIE_NAME + assert data.hostname == PLACEHOLDER_hostname + assert data.oidc_callback_path == PLACEHOLDER_OIDC_CALLBACK_PATH + + +def test_spoe_auth_provider_app_data_default_callback_path(): + """Create SpoeAuthProviderAppData with default callback path. + + arrange: Create a SpoeAuthProviderAppData model without specifying oidc_callback_path. + act: Validate the model. + assert: Model validation passes with default callback path. + """ + data = SpoeAuthProviderAppData( + spop_port=PLACEHOLDER_SPOP_PORT, + oidc_callback_port=PLACEHOLDER_OIDC_CALLBACK_PORT, + event=HaproxyEvent.ON_FRONTEND_HTTP_REQUEST, + message_name='try-auth-oidc', + var_authenticated=PLACEHOLDER_VAR_AUTHENTICATED, + var_redirect_url=PLACEHOLDER_VAR_REDIRECT_URL, + cookie_name=PLACEHOLDER_COOKIE_NAME, + hostname=PLACEHOLDER_hostname, + oidc_callback_path='/oauth2/callback', # Explicitly set to the default value + ) + + assert data.oidc_callback_path == '/oauth2/callback' + + +@pytest.mark.parametrize('port', [0, 65526]) +def test_spoe_auth_provider_app_data_invalid_spop_port(port: int): + """ + arrange: Create a SpoeAuthProviderAppData model with spop_port set to 0. + act: Validate the model. + assert: Validation raises an error. + """ + with pytest.raises(ValidationError): + SpoeAuthProviderAppData( + spop_port=port, # Invalid: port must be > 0 and <= 65525 + oidc_callback_port=PLACEHOLDER_OIDC_CALLBACK_PORT, + event=HaproxyEvent.ON_FRONTEND_HTTP_REQUEST, + message_name='try-auth-oidc', + var_authenticated=PLACEHOLDER_VAR_AUTHENTICATED, + var_redirect_url=PLACEHOLDER_VAR_REDIRECT_URL, + cookie_name=PLACEHOLDER_COOKIE_NAME, + hostname=PLACEHOLDER_hostname, + ) + + +@pytest.mark.parametrize('port', [0, 65526]) +def test_spoe_auth_provider_app_data_invalid_oidc_callback_port(port: int): + """ + arrange: Create a SpoeAuthProviderAppData model with invalid oidc_callback_port. + act: Validate the model. + assert: Validation raises an error. + """ + with pytest.raises(ValidationError): + SpoeAuthProviderAppData( + spop_port=PLACEHOLDER_SPOP_PORT, + oidc_callback_port=port, # Invalid: port must be > 0 and <= 65525 + event=HaproxyEvent.ON_FRONTEND_HTTP_REQUEST, + message_name='try-auth-oidc', + var_authenticated=PLACEHOLDER_VAR_AUTHENTICATED, + var_redirect_url=PLACEHOLDER_VAR_REDIRECT_URL, + cookie_name=PLACEHOLDER_COOKIE_NAME, + hostname=PLACEHOLDER_hostname, + ) + + +def test_spoe_auth_provider_app_data_invalid_hostname_format(): + """ + arrange: Create a SpoeAuthProviderAppData model with invalid hostname format. + act: Validate the model. + assert: Validation raises an error. + """ + with pytest.raises(ValidationError): + SpoeAuthProviderAppData( + spop_port=PLACEHOLDER_SPOP_PORT, + oidc_callback_port=PLACEHOLDER_OIDC_CALLBACK_PORT, + event=HaproxyEvent.ON_FRONTEND_HTTP_REQUEST, + message_name='try-auth-oidc', + var_authenticated=PLACEHOLDER_VAR_AUTHENTICATED, + var_redirect_url=PLACEHOLDER_VAR_REDIRECT_URL, + cookie_name=PLACEHOLDER_COOKIE_NAME, + hostname='invalid-hostname-!@#', # Invalid: contains special chars + ) + + +def test_spoe_auth_provider_app_data_invalid_char_in_var_authenticated(): + """ + arrange: Create a SpoeAuthProviderAppData model with invalid characters in var_authenticated. + act: Validate the model. + assert: Validation raises an error. + """ + with pytest.raises(ValidationError): + SpoeAuthProviderAppData( + spop_port=PLACEHOLDER_SPOP_PORT, + oidc_callback_port=PLACEHOLDER_OIDC_CALLBACK_PORT, + event=HaproxyEvent.ON_FRONTEND_HTTP_REQUEST, + message_name='try-auth-oidc', + var_authenticated='invalid\nvar', # Invalid: newline character + var_redirect_url=PLACEHOLDER_VAR_REDIRECT_URL, + cookie_name=PLACEHOLDER_COOKIE_NAME, + hostname=PLACEHOLDER_hostname, + ) + + +def test_spoe_auth_provider_unit_data_validation(): + """ + arrange: Create a SpoeAuthProviderUnitData model with valid data. + act: Validate the model. + assert: Model validation passes. + """ + data = SpoeAuthProviderUnitData(address=cast('IPvAnyAddress', PLACEHOLDER_ADDRESS)) + + assert str(data.address) == PLACEHOLDER_ADDRESS + + +def test_spoe_auth_provider_unit_data_ipv6_validation(): + """ + arrange: Create a SpoeAuthProviderUnitData model with IPv6 address. + act: Validate the model. + assert: Model validation passes. + """ + ipv6_address = '2001:db8::1' + data = SpoeAuthProviderUnitData(address=cast('IPvAnyAddress', ipv6_address)) + + assert str(data.address) == ipv6_address + + +def test_spoe_auth_provider_unit_data_invalid_address(): + """ + arrange: Create a SpoeAuthProviderUnitData model with invalid IP address. + act: Validate the model. + assert: Validation raises an error. + """ + with pytest.raises(ValidationError): + SpoeAuthProviderUnitData(address=cast('IPvAnyAddress', 'invalid-ip-address')) + + +def test_load_provider_app_data(mock_provider_app_data_dict: dict[str, Any]): + """ + arrange: Create a databag with valid provider application data. + act: Load the data with SpoeAuthProviderAppData.load(). + assert: Data is loaded correctly. + """ + databag = {k: json.dumps(v) for k, v in mock_provider_app_data_dict.items()} + data = cast('SpoeAuthProviderAppData', SpoeAuthProviderAppData.load(databag)) + + assert data.spop_port == PLACEHOLDER_SPOP_PORT + assert data.oidc_callback_port == PLACEHOLDER_OIDC_CALLBACK_PORT + assert data.event == HaproxyEvent.ON_FRONTEND_HTTP_REQUEST + assert data.var_authenticated == PLACEHOLDER_VAR_AUTHENTICATED + assert data.var_redirect_url == PLACEHOLDER_VAR_REDIRECT_URL + assert data.cookie_name == PLACEHOLDER_COOKIE_NAME + assert data.oidc_callback_path == PLACEHOLDER_OIDC_CALLBACK_PATH + assert data.hostname == PLACEHOLDER_hostname + assert data.message_name == 'try-auth-oidc' + + +def test_load_provider_app_data_invalid_databag(): + """ + arrange: Create a databag with invalid JSON. + act: Load the data with SpoeAuthProviderAppData.load(). + assert: DataValidationError is raised. + """ + invalid_databag = { + 'spop_port': 'not-json', + } + with pytest.raises(DataValidationError): + SpoeAuthProviderAppData.load(invalid_databag) + + +def test_dump_provider_app_data(): + """Dump provider app data to databag. + + arrange: Create a SpoeAuthProviderAppData model with valid data. + act: Dump the model to a databag. + assert: Databag contains correct data. + """ + data = SpoeAuthProviderAppData( + spop_port=PLACEHOLDER_SPOP_PORT, + oidc_callback_port=PLACEHOLDER_OIDC_CALLBACK_PORT, + event=HaproxyEvent.ON_FRONTEND_HTTP_REQUEST, + message_name='try-auth-oidc', + var_authenticated=PLACEHOLDER_VAR_AUTHENTICATED, + var_redirect_url=PLACEHOLDER_VAR_REDIRECT_URL, + cookie_name=PLACEHOLDER_COOKIE_NAME, + hostname=PLACEHOLDER_hostname, + oidc_callback_path=PLACEHOLDER_OIDC_CALLBACK_PATH, + ) + + databag: dict[str, Any] = {} + result = data.dump(databag) + + assert result is not None + assert 'spop_port' in databag + assert json.loads(databag['spop_port']) == PLACEHOLDER_SPOP_PORT + assert json.loads(databag['oidc_callback_port']) == PLACEHOLDER_OIDC_CALLBACK_PORT + assert json.loads(databag['event']) == 'on-frontend-http-request' + assert json.loads(databag['var_authenticated']) == PLACEHOLDER_VAR_AUTHENTICATED + assert json.loads(databag['var_redirect_url']) == PLACEHOLDER_VAR_REDIRECT_URL + assert json.loads(databag['cookie_name']) == PLACEHOLDER_COOKIE_NAME + # oidc_callback_path should be included when explicitly set + if 'oidc_callback_path' in databag: + assert json.loads(databag['oidc_callback_path']) == PLACEHOLDER_OIDC_CALLBACK_PATH + assert json.loads(databag['hostname']) == PLACEHOLDER_hostname + assert json.loads(databag['message_name']) == 'try-auth-oidc' + + +def test_dump_and_load_provider_app_data_roundtrip(): + """ + arrange: Create a SpoeAuthProviderAppData model. + act: Dump and then load it again. + assert: The loaded data matches the original. + """ + original_data = SpoeAuthProviderAppData( + spop_port=PLACEHOLDER_SPOP_PORT, + oidc_callback_port=PLACEHOLDER_OIDC_CALLBACK_PORT, + event=HaproxyEvent.ON_FRONTEND_HTTP_REQUEST, + message_name='try-auth-oidc', + var_authenticated=PLACEHOLDER_VAR_AUTHENTICATED, + var_redirect_url=PLACEHOLDER_VAR_REDIRECT_URL, + cookie_name=PLACEHOLDER_COOKIE_NAME, + hostname=PLACEHOLDER_hostname, + oidc_callback_path=PLACEHOLDER_OIDC_CALLBACK_PATH, + ) + + # Dump to databag + databag: dict[str, Any] = {} + original_data.dump(databag) + + # Load from databag + loaded_data = cast('SpoeAuthProviderAppData', SpoeAuthProviderAppData.load(databag)) + + assert loaded_data.spop_port == original_data.spop_port + assert loaded_data.oidc_callback_port == original_data.oidc_callback_port + assert loaded_data.event == original_data.event + assert loaded_data.var_authenticated == original_data.var_authenticated + assert loaded_data.var_redirect_url == original_data.var_redirect_url + assert loaded_data.cookie_name == original_data.cookie_name + assert loaded_data.hostname == original_data.hostname + assert loaded_data.oidc_callback_path == original_data.oidc_callback_path + + +def test_load_provider_unit_data(mock_provider_unit_data_dict: dict[str, str]): + """ + arrange: Create a databag with valid unit data. + act: Load the data with SpoeAuthProviderUnitData.load(). + assert: Data is loaded correctly. + """ + databag = {k: json.dumps(v) for k, v in mock_provider_unit_data_dict.items()} + data = cast('SpoeAuthProviderUnitData', SpoeAuthProviderUnitData.load(databag)) + + assert str(data.address) == PLACEHOLDER_ADDRESS + + +def test_dump_provider_unit_data(): + """ + arrange: Create a SpoeAuthProviderUnitData model with valid data. + act: Dump the model to a databag. + assert: Databag contains correct data. + """ + data = SpoeAuthProviderUnitData(address=cast('IPvAnyAddress', PLACEHOLDER_ADDRESS)) + + databag: dict[str, Any] = {} + result = data.dump(databag) + + assert result is not None + assert 'address' in databag + assert json.loads(databag['address']) == PLACEHOLDER_ADDRESS + + +def test_dump_and_load_provider_unit_data_roundtrip(): + """ + arrange: Create a SpoeAuthProviderUnitData model. + act: Dump and then load it again. + assert: The loaded data matches the original. + """ + original_data = SpoeAuthProviderUnitData(address=cast('IPvAnyAddress', PLACEHOLDER_ADDRESS)) + + # Dump to databag + databag: dict[str, Any] = {} + original_data.dump(databag) + + # Load from databag + loaded_data = cast('SpoeAuthProviderUnitData', SpoeAuthProviderUnitData.load(databag)) + + assert str(loaded_data.address) == str(original_data.address) + + +@pytest.mark.parametrize( + 'hostname,is_valid', + [ + ('example.com', True), + ('sub.example.com', True), + ('test.sub.example.com', True), + ('a.b.c.d.e.f.g.example.com', True), + ('test-123.example.com', True), + ('a.example.com', True), + ('test.example-with-dash.com', True), + ('very-long-subdomain-name-that-is-still-valid.example.com', True), + ('x.y', True), + ('123test.example.com', False), # Must start with a letter + ('example', False), # No TLD + ('-example.com', False), # Starts with hyphen + ('example-.com', False), # Ends with hyphen + ('ex--ample.com', False), # Double hyphen + ('example..com', False), # Double dots + ('.example.com', False), # Starts with dot + ('example.com.', False), # Ends with dot + ('example.', False), # Ends with dot after TLD + ('example..', False), # Multiple dots at end + ('', False), # Empty string + ('a' * 64 + '.com', False), # Label too long (>63 chars) + ('invalid-hostname-!@#.com', False), # Invalid characters + ('example with spaces.com', False), # Spaces not allowed + ('example\nnewline.com', False), # Newline not allowed + ('UPPERCASE.COM', True), # Should be valid (case insensitive) + ('mixed-Case.Example.COM', True), # Mixed case should be valid + ], +) +def test_hostname_regex_validation(hostname: str, is_valid: bool): + """Test HOSTNAME_REGEX validates FQDNs correctly. + + arrange: Test various hostname strings against HOSTNAME_REGEX. + act: Check if the hostname matches the regex pattern. + assert: The result matches the expected validity. + """ + match = re.match(HOSTNAME_REGEX, hostname) + if is_valid: + assert match is not None, f"Expected '{hostname}' to be valid but regex didn't match" + else: + assert match is None, f"Expected '{hostname}' to be invalid but regex matched" diff --git a/interfaces/haproxy_spoe_auth/tests/unit/test_version.py b/interfaces/haproxy_spoe_auth/tests/unit/test_version.py new file mode 100644 index 000000000..ef58e77d8 --- /dev/null +++ b/interfaces/haproxy_spoe_auth/tests/unit/test_version.py @@ -0,0 +1,21 @@ +# Copyright 2025 Canonical Ltd. +# +# 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. + +"""Unit tests for library code, not involving charm code.""" + +from charmlibs.interfaces import haproxy_spoe_auth + + +def test_version(): + assert isinstance(haproxy_spoe_auth.__version__, str) diff --git a/interfaces/haproxy_spoe_auth/tests/unit/test_version_in_charm.py b/interfaces/haproxy_spoe_auth/tests/unit/test_version_in_charm.py new file mode 100644 index 000000000..a55dc19d0 --- /dev/null +++ b/interfaces/haproxy_spoe_auth/tests/unit/test_version_in_charm.py @@ -0,0 +1,38 @@ +# Copyright 2025 Canonical Ltd. +# +# 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. + +"""Light weight state-transition tests of the library in a charming context.""" + +import ops +import ops.testing + +from charmlibs.interfaces import haproxy_spoe_auth + + +class Charm(ops.CharmBase): + package_version: str + + def __init__(self, framework: ops.Framework): + super().__init__(framework) + framework.observe(self.on.start, self._on_start) + + def _on_start(self, event: ops.StartEvent): + self.package_version = haproxy_spoe_auth.__version__ + + +def test_version(): + ctx = ops.testing.Context(Charm, meta={'name': 'charm'}) + with ctx(ctx.on.start(), ops.testing.State()) as manager: + manager.run() + assert isinstance(manager.charm.package_version, str) diff --git a/interfaces/haproxy_spoe_auth/uv.lock b/interfaces/haproxy_spoe_auth/uv.lock new file mode 100644 index 000000000..9b4426e87 --- /dev/null +++ b/interfaces/haproxy_spoe_auth/uv.lock @@ -0,0 +1,280 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[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 = "charmlibs-interfaces-haproxy-spoe-auth" +source = { editable = "." } +dependencies = [ + { name = "ops" }, + { name = "pydantic" }, +] + +[package.dev-dependencies] +integration = [ + { name = "jubilant" }, +] +unit = [ + { name = "ops", extra = ["testing"] }, +] + +[package.metadata] +requires-dist = [ + { name = "ops", specifier = ">=3.3.1" }, + { name = "pydantic", specifier = ">=2.12.4" }, +] + +[package.metadata.requires-dev] +functional = [] +integration = [{ name = "jubilant" }] +lint = [] +unit = [{ name = "ops", extras = ["testing"] }] + +[[package]] +name = "importlib-metadata" +version = "8.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, +] + +[[package]] +name = "jubilant" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/af/2c7d2a677389eb4de3bb841f399b749ca2fd4c6c1b70313e21249536e6be/jubilant-1.5.0.tar.gz", hash = "sha256:055c65a662586191939a1a3d3e2b6d08b71ecf0c7f403a1c7ba0cde6ecaf3bbd", size = 28433, upload-time = "2025-10-10T01:08:06.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/c9/05e5aa65baa7b71b17c1314b8f25744f36fd9aad60ef9fb46fd84347885a/jubilant-1.5.0-py3-none-any.whl", hash = "sha256:eec58340b9d3d478f31e18e33281638fdf0d9b84608c8935368f7a1bb1972255", size = 28275, upload-time = "2025-10-10T01:08:04.963Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/d8/0f354c375628e048bd0570645b310797299754730079853095bf000fba69/opentelemetry_api-1.38.0.tar.gz", hash = "sha256:f4c193b5e8acb0912b06ac5b16321908dd0843d75049c091487322284a3eea12", size = 65242, upload-time = "2025-10-16T08:35:50.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/a2/d86e01c28300bd41bab8f18afd613676e2bd63515417b77636fc1add426f/opentelemetry_api-1.38.0-py3-none-any.whl", hash = "sha256:2891b0197f47124454ab9f0cf58f3be33faca394457ac3e09daba13ff50aa582", size = 65947, upload-time = "2025-10-16T08:35:30.23Z" }, +] + +[[package]] +name = "ops" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "pyyaml" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/cd/bbd4263b53bb19e87f995b05ef93f565fa4edcb90bacacec615d47c56568/ops-3.3.1.tar.gz", hash = "sha256:8dec621c32a31365d040eaf05960c0e65a2ffbcdbfa91107e03b1ee9d4d26034", size = 541783, upload-time = "2025-10-16T01:46:25.431Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/97/c1e5447c3c0d86cab16cad802e1bb58613b2396b7184797af2e4484c2014/ops-3.3.1-py3-none-any.whl", hash = "sha256:38229bb1cc6d9c2aa4dff4495f3e33af928dbd1070e8c5d8b697dfb32dcd89a8", size = 191338, upload-time = "2025-10-16T01:46:20.038Z" }, +] + +[package.optional-dependencies] +testing = [ + { name = "ops-scenario" }, +] + +[[package]] +name = "ops-scenario" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ops" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/94/cf17208427d43f9136b0fb61ca75ac937b88ac66cb0a3fe126bbffbde677/ops_scenario-8.3.1.tar.gz", hash = "sha256:553cd1ee30f161edd110e217a01505a9778baf778c039d810e0cfbd6101b855b", size = 110260, upload-time = "2025-10-16T01:46:27.097Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/dd/da2bcb1a164c4c9c8690c689305b15c3f988166db44add5084c4dc04ad04/ops_scenario-8.3.1-py3-none-any.whl", hash = "sha256:bf8404387cd8c22cd0b45c996ca7c12e84b6c603edc8b056e85234fc3d270941", size = 64403, upload-time = "2025-10-16T01:46:22.082Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038, upload-time = "2025-11-05T10:50:08.59Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400, upload-time = "2025-11-05T10:50:06.732Z" }, +] + +[[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/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/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" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "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 = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +]