diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..783ca12 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,35 @@ + + +## Description + + +## Motivation and Context + + +- [ ] I have raised an issue to propose this change ([required](https://github.com/openfaas/faas/blob/master/CONTRIBUTING.md)) +- [ ] My issue has received approval from the maintainers or lead with the `design/approved` label + + +## How Has This Been Tested? + + + + + +## Types of changes + +- [ ] 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 change) + + +## Checklist: + + +- [ ] My code follows the code style of this project. +- [ ] My change requires a change to the documentation. +- [ ] I have updated the documentation accordingly. +- [ ] I've read the [CONTRIBUTION](https://github.com/openfaas/faas/blob/master/CONTRIBUTING.md) guide +- [ ] I have signed-off my commits with `git commit -s` +- [ ] I have added tests to cover my changes. +- [ ] All new and existing tests passed. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6391b2e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,64 @@ +name: CI + +on: + push: + pull_request: + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: uv sync + + - name: Check lint + run: uv run ruff check . + + - name: Check formatting + run: uv run ruff format --check . + + typecheck: + name: Type check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: uv sync + + - name: Run pyright + run: uv run pyright + + test: + name: Test (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync + + - name: Run tests + run: uv run pytest tests/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7e64d2b --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +.venv/ +.pytest_cache/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4b04150 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,65 @@ +# Contributing + +## Requirements + +- Python 3.10 or newer +- [uv](https://docs.astral.sh/uv/) for dependency management + +## Setup + +Clone the repository and install all dependencies including dev tools: + +```bash +git clone https://github.com/openfaas/python-sdk +cd python-sdk +uv sync +``` + +## Running tests + +```bash +uv run pytest tests/ +``` + +## Linting + +Check for lint issues: + +```bash +uv run ruff check . +``` + +Fix lint issues automatically: + +```bash +uv run ruff check --fix . +``` + +## Formatting + +Check formatting: + +```bash +uv run ruff format --check . +``` + +Apply formatting: + +```bash +uv run ruff format . +``` + +## Type checking + +```bash +uv run pyright +``` + +## CI + +All of the above are run automatically on every push and pull request via +GitHub Actions. The workflow runs: + +- **Lint** — `ruff check` and `ruff format --check` +- **Type check** — `pyright` +- **Test** — `pytest` across Python 3.10, 3.11, 3.12, and 3.13 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..21b8a1b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenFaaS Ltd + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index bf51a1c..3f370f6 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,463 @@ -# python-sdk -OpenFaaS Python SDK +# OpenFaaS Python SDK + +The official Python SDK for [OpenFaaS](https://www.openfaas.com). + +## Features + +- Full coverage of the OpenFaaS REST API — functions, namespaces, secrets, logs, system info, and function invocation +- Synchronous `Client` backed by `requests` +- Multiple auth strategies: Basic auth, OpenFaaS IAM (token exchange), OAuth2 client credentials +- Pydantic v2 models for all request and response types — validated, typed, IDE-friendly +- Streaming log support via iterators +- `FunctionBuilder` client for the [OpenFaaS Pro Function Builder API](https://docs.openfaas.com/openfaas-pro/builder/) — build and push function images from source +- `FAAS_DEBUG=1` environment variable for request/response logging (auth headers redacted) +- Context manager support for automatic connection cleanup + +## Requirements + +- Python 3.10+ +- [requests](https://requests.readthedocs.io) >= 2.20 +- [pydantic](https://docs.pydantic.dev) >= 2.0 + +## Installation + +```bash +pip install openfaas-sdk +``` + +## Quick start + +```python +from openfaas import Client, BasicAuth + +client = Client( + gateway_url="https://gateway.example.com", + auth=BasicAuth("admin", "secret"), +) + +functions = client.get_functions("openfaas-fn") +for fn in functions: + print(fn.name, fn.replicas) + +client.close() +``` + +Use the client as a context manager to ensure connections are closed: + +```python +from openfaas import Client, BasicAuth + +with Client("https://gateway.example.com", auth=BasicAuth("admin", "secret")) as client: + functions = client.get_functions("openfaas-fn") +``` + +## Authentication + +### Basic auth + +```python +from openfaas import BasicAuth + +auth = BasicAuth(username="admin", password="secret") +``` + +The password can be read from a file: + +```python +from openfaas import BasicAuth + +with open("/var/secrets/basic-auth-password") as f: + password = f.read().strip() + +auth = BasicAuth(username="admin", password=password) +``` + +### Custom auth + +Subclass `requests.auth.AuthBase` directly to implement your own strategy: + +```python +import requests.auth + +class MyTokenAuth(requests.auth.AuthBase): + def __init__(self, token: str) -> None: + self._token = token + + def __call__(self, r: requests.PreparedRequest) -> requests.PreparedRequest: + r.headers["Authorization"] = f"Bearer {self._token}" + return r +``` + +### OpenFaaS IAM — Kubernetes workload identity + +When running inside a Kubernetes cluster with [OpenFaaS IAM](https://docs.openfaas.com/openfaas-pro/iam/overview/) enabled, use `TokenAuth` with `ServiceAccountTokenSource` to exchange the pod's projected service account token for an OpenFaaS gateway JWT automatically: + +```python +from openfaas import Client, TokenAuth, ServiceAccountTokenSource + +auth = TokenAuth( + token_url="https://gateway.example.com/oauth/token", + token_source=ServiceAccountTokenSource(), +) + +with Client("https://gateway.example.com", auth=auth) as client: + functions = client.get_functions("openfaas-fn") +``` + +`ServiceAccountTokenSource` re-reads `/var/secrets/tokens/openfaas-token` on every call so that Kubernetes token rotation is handled transparently. The path can be overridden with the `token_mount_path` environment variable. + +`TokenAuth` caches the exchanged gateway token and refreshes it automatically when it expires (10-second expiry buffer). + +`TokenAuth` also implements the `TokenSource` protocol, so it is automatically used as the `function_token_source` for per-function scoped token exchange when calling `get_function_token()`. + +### OpenFaaS IAM — external IdP via client credentials + +For workloads outside Kubernetes, use `ClientCredentialsTokenSource` to obtain tokens from an external IdP and exchange them for an OpenFaaS gateway JWT: + +```python +from openfaas import Client, TokenAuth, ClientCredentialsTokenSource + +ts = ClientCredentialsTokenSource( + client_id="my-app", + client_secret="secret", + token_url="https://idp.example.com/realms/master/protocol/openid-connect/token", + scope="openid", +) +auth = TokenAuth( + token_url="https://gateway.example.com/oauth/token", + token_source=ts, +) + +with Client("https://gateway.example.com", auth=auth) as client: + functions = client.get_functions("openfaas-fn") +``` + +### Per-function scoped tokens + +`get_function_token()` exchanges the current identity token for a short-lived token scoped to a specific function (audience `":"`). Use this token when invoking functions directly: + +```python +token = client.get_function_token("my-func", "openfaas-fn") +# token is a raw JWT string — pass it as a Bearer token when invoking the function +``` + +## API reference + +### System + +```python +info = client.get_info() +# info.arch, info.provider.orchestration, info.version.release +``` + +### Functions + +```python +# List all functions in a namespace +functions = client.get_functions("openfaas-fn") + +# Get a single function +fn = client.get_function("env", "openfaas-fn") +# fn.name, fn.replicas, fn.available_replicas, fn.invocation_count + +# Deploy a new function +from openfaas import FunctionDeployment, FunctionResources + +spec = FunctionDeployment( + service="env", + image="ghcr.io/openfaas/env:latest", + namespace="openfaas-fn", + labels={"com.openfaas.scale.min": "1"}, + limits=FunctionResources(memory="128Mi", cpu="100m"), +) +client.deploy(spec) + +# Update an existing function +spec.image = "ghcr.io/openfaas/env:0.2.0" +client.update(spec) + +# Scale a function +client.scale_function("env", replicas=3, namespace="openfaas-fn") + +# Delete a function +client.delete_function("env", "openfaas-fn") +``` + +### Namespaces + +```python +# List all namespaces +namespaces = client.get_namespaces() # ["openfaas-fn", "staging"] + +# Get namespace details +ns = client.get_namespace("openfaas-fn") + +# Create a namespace +from openfaas import FunctionNamespace + +client.create_namespace(FunctionNamespace(name="staging", labels={"team": "backend"})) + +# Update a namespace +client.update_namespace(FunctionNamespace(name="staging", annotations={"owner": "alice"})) + +# Delete a namespace +client.delete_namespace("staging") +``` + +### Secrets + +```python +from openfaas import Secret + +# List secrets +secrets = client.get_secrets("openfaas-fn") + +# Create a secret +client.create_secret(Secret(name="db-password", namespace="openfaas-fn", value="s3cr3t")) + +# Update a secret +client.update_secret(Secret(name="db-password", namespace="openfaas-fn", value="n3w-s3cr3t")) + +# Delete a secret +client.delete_secret("db-password", namespace="openfaas-fn") +``` + +### Logs + +`get_logs` returns a lazy iterator that streams NDJSON log lines from the gateway. + +```python +# Get the last 100 lines +for msg in client.get_logs("env", "openfaas-fn", tail=100): + print(f"[{msg.timestamp}] {msg.instance}: {msg.text}") + +# Follow (stream) logs +for msg in client.get_logs("env", "openfaas-fn", follow=True): + print(msg.text) +``` + +Filter by time: + +```python +from datetime import datetime, timezone + +since = datetime(2024, 1, 1, tzinfo=timezone.utc) +for msg in client.get_logs("env", namespace="openfaas-fn", since=since): + print(msg.text) +``` + +### Function invocation + +`invoke_function` returns the raw `requests.Response` from the function. Non-2xx responses are **not** raised as exceptions — function responses are application-level and the caller decides how to interpret them. + +```python +# POST with a bytes or str payload +resp = client.invoke_function("env", method="POST", payload=b"hello") +print(resp.status_code, resp.text) + +# GET with no payload +resp = client.invoke_function("env", method="GET") + +# Custom namespace +resp = client.invoke_function("env", "staging", method="POST") + +# Pass extra headers and query parameters +resp = client.invoke_function( + "env", + method="POST", + payload="hello", + headers={"Content-Type": "text/plain"}, + query_params={"verbose": "1"}, +) +``` + +### Async (queued) invocation + +`invoke_function_async` queues the invocation via the gateway's `/async-function/` route and returns immediately with a `202 Accepted` response. The function result is not returned synchronously. + +```python +# Async invocation — returns 202 immediately +client.invoke_function_async("env", payload=b"data") + +# Async invocation with a callback URL +client.invoke_function_async( + "env", + payload=b"data", + callback_url="https://my-service.example.com/callback", +) +``` + +#### IAM-scoped function invocation + +When OpenFaaS IAM is enabled, use `use_function_auth=True` to automatically +obtain a per-function scoped token and attach it as a Bearer token. This +requires the client to be configured with a `TokenAuth` (or any +`function_token_source`): + +```python +from openfaas import Client, TokenAuth, ServiceAccountTokenSource + +auth = TokenAuth( + token_url="https://gateway.example.com/oauth/token", + token_source=ServiceAccountTokenSource(), +) + +with Client("https://gateway.example.com", auth=auth) as client: + resp = client.invoke_function("my-func", "openfaas-fn", method="POST", use_function_auth=True) + print(resp.text) +``` + +## Error handling + +```python +from openfaas import Client, BasicAuth +from openfaas.exceptions import NotFoundError, UnauthorizedError, ForbiddenError, APIConnectionError + +with Client("https://gateway.example.com", auth=BasicAuth("admin", "secret")) as client: + try: + fn = client.get_function("my-fn", "openfaas-fn") + except NotFoundError: + print("Function does not exist") + except UnauthorizedError: + print("Invalid credentials") + except ForbiddenError: + print("Insufficient permissions") + except APIConnectionError: + print("Could not reach the gateway") +``` + +| Exception | HTTP status | +|---|---| +| `NotFoundError` | 404 | +| `UnauthorizedError` | 401 | +| `ForbiddenError` | 403 | +| `UnexpectedStatusError` | any other non-2xx | +| `APIConnectionError` | network / timeout | + +All `APIStatusError` subclasses expose `.status_code` and `.response` (the raw `requests.Response`). + +## Configuration + +### Timeout + +```python +# Default timeout for all requests (seconds) +client = Client("https://gateway.example.com", auth=auth, timeout=60.0) +``` + +### Custom HTTP client + +Pass a pre-configured `requests.Session` to customise proxies, SSL, or other transport options: + +```python +import requests +from openfaas import Client + +session = requests.Session() +session.verify = "/path/to/ca-bundle.pem" +session.proxies = {"https": "http://proxy.corp.example.com"} +client = Client("https://gateway.example.com", auth=auth, http_client=session) +``` + +### Debug logging + +Set `FAAS_DEBUG=1` to log all requests and responses. The `Authorization` header is automatically redacted. + +```bash +FAAS_DEBUG=1 python my_script.py +``` + +Configure the log level in your application to see the output: + +```python +import logging +logging.basicConfig(level=logging.DEBUG) +``` + +## Function Builder + +The `FunctionBuilder` client interacts with the [OpenFaaS Pro Function Builder API](https://docs.openfaas.com/openfaas-pro/builder/) to build and push function images from source code. + +### Assemble a build context + +`create_build_context` prepares a Docker build context on disk from a template and a handler directory. Templates can be pulled with `faas-cli template store pull ` or fetched from any other source. + +```python +from openfaas.builder import create_build_context, BuildConfig, make_tar, FunctionBuilder + +# 1. Assemble the build context from template + handler +context_path = create_build_context( + function_name="hello-world", + handler="./hello-world", # directory containing your function code + language="python3", + template_dir="./template", # directory containing pulled templates + build_dir="./build", +) + +# 2. Pack the context into a tar archive +config = BuildConfig( + image="ttl.sh/hello-world:1h", + platforms=["linux/amd64"], +) +make_tar("/tmp/req.tar", context_path, config) +``` + +### Build without streaming + +`build()` blocks until the builder returns a complete result: + +```python +builder = FunctionBuilder( + "http://127.0.0.1:8081", + hmac_secret="my-hmac-secret", # matches the payload-secret in the cluster +) + +result = builder.build("/tmp/req.tar") +print(result.status) # "success" / "failed" +print(result.image) # fully-qualified image name +for line in result.log: + print(line) +``` + +### Build with streaming + +`build_stream()` yields `BuildResult` objects as NDJSON lines arrive, allowing you to display log output in real time: + +```python +for result in builder.build_stream("/tmp/req.tar"): + for line in result.log: + print(line) + if result.status in ("success", "failed"): + print("Final status:", result.status) +``` + +### Skip push + +Set `skip_push=True` on `BuildConfig` to build the image without pushing it to a registry: + +```python +config = BuildConfig(image="ttl.sh/hello-world:1h", skip_push=True) +``` + +### HMAC request signing + +When `hmac_secret` is provided, every request is signed with an HMAC-SHA256 digest sent in the `X-Build-Signature` header. The secret must match the `payload-secret` configured in the builder deployment: + +```bash +kubectl get secret -n openfaas payload-secret \ + -o jsonpath='{.data.payload-secret}' | base64 --decode +``` + +## Development + +```bash +# Install dependencies +uv sync + +# Run tests +uv run python -m pytest -v +``` + +## License + +MIT diff --git a/openfaas/__init__.py b/openfaas/__init__.py new file mode 100644 index 0000000..7de165b --- /dev/null +++ b/openfaas/__init__.py @@ -0,0 +1,121 @@ +""" +OpenFaaS Python SDK +=================== + +A Python client for the OpenFaaS gateway API. + +Quickstart — Basic auth:: + + from openfaas import Client, BasicAuth + + with Client("https://gateway.example.com", auth=BasicAuth("admin", "secret")) as client: + functions = client.get_functions("openfaas-fn") + for fn in functions: + print(fn.name, fn.replicas) + +Quickstart — IAM auth (Kubernetes workload):: + + from openfaas import Client, TokenAuth, ServiceAccountTokenSource + + auth = TokenAuth( + token_url="https://gateway.example.com/oauth/token", + token_source=ServiceAccountTokenSource(), + ) + with Client("https://gateway.example.com", auth=auth) as client: + functions = client.get_functions("openfaas-fn") +""" + +from openfaas._version import __version__ +from openfaas.auth import ( + BasicAuth, + ClientCredentialsTokenSource, + ServiceAccountTokenSource, + TokenAuth, + TokenSource, +) +from openfaas.builder import ( + BUILD_FAILED, + BUILD_IN_PROGRESS, + BUILD_SUCCESS, + BUILDER_CONFIG_FILE_NAME, + BuildConfig, + BuildResult, + FunctionBuilder, + create_build_context, + make_tar, +) +from openfaas.client import Client +from openfaas.exceptions import ( + APIConnectionError, + APIStatusError, + ForbiddenError, + NotFoundError, + OpenFaaSError, + UnauthorizedError, + UnexpectedStatusError, +) +from openfaas.exchange import exchange_id_token +from openfaas.models import ( + FunctionDeployment, + FunctionNamespace, + FunctionResources, + FunctionStatus, + FunctionUsage, + LogMessage, + Provider, + Secret, + SystemInfo, + VersionInfo, +) +from openfaas.token import OAuthError, Token +from openfaas.token_cache import MemoryTokenCache, TokenCache + +__all__ = [ + # Version + "__version__", + # Builder + "FunctionBuilder", + "BuildConfig", + "BuildResult", + "BUILDER_CONFIG_FILE_NAME", + "BUILD_IN_PROGRESS", + "BUILD_SUCCESS", + "BUILD_FAILED", + "make_tar", + "create_build_context", + # Clients + "Client", + # Auth + "TokenSource", + "BasicAuth", + "TokenAuth", + "ServiceAccountTokenSource", + "ClientCredentialsTokenSource", + # Token exchange + "exchange_id_token", + # Token + "Token", + "OAuthError", + # Token cache + "TokenCache", + "MemoryTokenCache", + # Exceptions + "OpenFaaSError", + "APIConnectionError", + "APIStatusError", + "NotFoundError", + "UnauthorizedError", + "ForbiddenError", + "UnexpectedStatusError", + # Models + "FunctionDeployment", + "FunctionNamespace", + "FunctionResources", + "FunctionStatus", + "FunctionUsage", + "LogMessage", + "Provider", + "Secret", + "SystemInfo", + "VersionInfo", +] diff --git a/openfaas/_transport.py b/openfaas/_transport.py new file mode 100644 index 0000000..39d3c09 --- /dev/null +++ b/openfaas/_transport.py @@ -0,0 +1,43 @@ +""" +Internal HTTP transport for the OpenFaaS Python SDK. + +Responsibilities: +- Injects the ``User-Agent: openfaas-python-sdk/`` header on every request. +- Logs request/response details when the ``FAAS_DEBUG`` environment variable is + set to ``1`` (Authorization header is redacted). + +This module is private — do not import it directly from user code. +""" + +from __future__ import annotations + +import logging +import os + +import requests + +from openfaas._version import __version__ + +logger = logging.getLogger("openfaas") + +_USER_AGENT = f"openfaas-python-sdk/{__version__}" + + +def _is_debug() -> bool: + return os.environ.get("FAAS_DEBUG", "").strip() == "1" + + +def _on_response(r: requests.Response, **_: object) -> None: + """requests event hook: log responses when FAAS_DEBUG=1.""" + if _is_debug(): + logger.debug("← %s %s", r.status_code, r.url) + + +def build_session(timeout: float = 30.0) -> requests.Session: + """Return a configured :class:`requests.Session` with the SDK User-Agent.""" + session = requests.Session() + session.headers.update({"User-Agent": _USER_AGENT}) + session.hooks["response"].append(_on_response) + # Store default timeout on the session for use in _request() + session._openfaas_timeout = timeout # type: ignore[attr-defined] + return session diff --git a/openfaas/_version.py b/openfaas/_version.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/openfaas/_version.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/openfaas/auth.py b/openfaas/auth.py new file mode 100644 index 0000000..1854d15 --- /dev/null +++ b/openfaas/auth.py @@ -0,0 +1,280 @@ +""" +Authentication for the OpenFaaS Python SDK. + +All auth classes subclass :class:`requests.auth.AuthBase`, which means they +work with :class:`requests.Session` and can be passed anywhere requests +accepts an ``auth=`` argument. + +Provided implementations: + +* :class:`BasicAuth` — HTTP Basic authentication +* :class:`TokenAuth` — OpenFaaS IAM token exchange auth +* :class:`ServiceAccountTokenSource` — Reads a Kubernetes projected service + account token from disk +* :class:`ClientCredentialsTokenSource` — Fetches tokens from an IdP via the + OAuth 2.0 client_credentials grant +Token source protocol: + +* :class:`TokenSource` — anything with a ``token() -> str`` method +""" + +from __future__ import annotations + +import logging +import os +import threading +from typing import Protocol, runtime_checkable + +import requests +import requests.auth + +from openfaas.exchange import exchange_id_token +from openfaas.token import OAuthError, Token, parse_token_response + +logger = logging.getLogger("openfaas") + + +# --------------------------------------------------------------------------- +# Token source protocol +# --------------------------------------------------------------------------- + + +@runtime_checkable +class TokenSource(Protocol): + """Protocol for objects that can provide a raw identity token synchronously. + + Any object with a ``token() -> str`` method satisfies this protocol. + + **Thread safety:** ``token()`` may be called concurrently from multiple + threads. Implementations must be aware of this and handle concurrent + calls safely. + """ + + def token(self) -> str: + """Return a raw JWT identity token string.""" + ... + + +# --------------------------------------------------------------------------- +# Basic auth +# --------------------------------------------------------------------------- + + +class BasicAuth(requests.auth.HTTPBasicAuth): + """HTTP Basic authentication using a username and password. + + Example:: + + auth = BasicAuth(username="admin", password="secret") + client = Client("https://gateway.example.com", auth=auth) + + The password can be read from a mounted secret file:: + + with open("/var/secrets/basic-auth-password") as f: + auth = BasicAuth("admin", f.read().strip()) + """ + + def __repr__(self) -> str: + return f"BasicAuth(username={self.username!r})" + + +# --------------------------------------------------------------------------- +# Token auth (OpenFaaS IAM) +# --------------------------------------------------------------------------- + + +class TokenAuth(requests.auth.AuthBase): + """OpenFaaS IAM authentication via OAuth 2.0 token exchange. + + Wraps an upstream :class:`TokenSource` and exchanges the upstream identity + token for an OpenFaaS gateway JWT on first use, then caches and + auto-refreshes it. + + ``TokenAuth`` also implements :class:`TokenSource`, so it can be used + directly as a ``function_token_source`` on the client — enabling + per-function scoped token exchange for function invocation. + + Example — Kubernetes workload:: + + from openfaas import Client, TokenAuth, ServiceAccountTokenSource + + auth = TokenAuth( + token_url="https://gateway.example.com/oauth/token", + token_source=ServiceAccountTokenSource(), + ) + client = Client("https://gateway.example.com", auth=auth) + + Example — external IdP via client credentials:: + + from openfaas import Client, TokenAuth, ClientCredentialsTokenSource + + ts = ClientCredentialsTokenSource( + client_id="my-app", + client_secret="secret", + token_url="https://idp.example.com/token", + scope="openid", + ) + auth = TokenAuth(token_url="https://gateway.example.com/oauth/token", token_source=ts) + client = Client("https://gateway.example.com", auth=auth) + """ + + def __init__(self, token_url: str, token_source: TokenSource) -> None: + self._token_url = token_url + self._token_source = token_source + self._token: Token | None = None + self._lock = threading.Lock() + + def __call__(self, r: requests.PreparedRequest) -> requests.PreparedRequest: + r.headers["Authorization"] = f"Bearer {self.token()}" + return r + + # TokenSource protocol ------------------------------------------------ + + def token(self) -> str: + """Return a valid gateway token, exchanging a new one if necessary.""" + with self._lock: + if self._token is None or self._token.is_expired(): + id_token = self._token_source.token() + try: + self._token = exchange_id_token(self._token_url, id_token) + except OAuthError: + raise + except Exception as exc: + raise RuntimeError(f"Failed to exchange token for an OpenFaaS token: {exc}") from exc + return self._token.id_token + + def __repr__(self) -> str: + return f"TokenAuth(token_url={self._token_url!r})" + + +# --------------------------------------------------------------------------- +# Kubernetes service account token source +# --------------------------------------------------------------------------- + +_DEFAULT_TOKEN_MOUNT_PATH = "/var/secrets/tokens" +_TOKEN_FILENAME = "openfaas-token" + + +class ServiceAccountTokenSource: + """Reads a Kubernetes projected service account token from disk. + + The token is read from ``/openfaas-token`` on every + call to :meth:`token`. The file is re-read each time rather than + cached because Kubernetes rotates projected tokens in-place. + + The mount path defaults to ``/var/secrets/tokens`` and can be overridden + via the ``token_mount_path`` environment variable. + + Example:: + + from openfaas import TokenAuth, ServiceAccountTokenSource + + auth = TokenAuth( + token_url="https://gateway.example.com/oauth/token", + token_source=ServiceAccountTokenSource(), + ) + """ + + def token(self) -> str: + """Read and return the raw service account token from disk.""" + mount_path = os.environ.get("token_mount_path", _DEFAULT_TOKEN_MOUNT_PATH).strip() + if not mount_path: + raise ValueError( + "Invalid token_mount_path: path is empty. Set the 'token_mount_path' environment variable." + ) + token_path = os.path.join(mount_path, _TOKEN_FILENAME) + try: + with open(token_path) as f: + return f.read().strip() + except OSError as exc: + raise RuntimeError(f"Unable to load service account token from {token_path}: {exc}") from exc + + def __repr__(self) -> str: + mount_path = os.environ.get("token_mount_path", _DEFAULT_TOKEN_MOUNT_PATH) + return f"ServiceAccountTokenSource(path={os.path.join(mount_path, _TOKEN_FILENAME)!r})" + + +# --------------------------------------------------------------------------- +# Client credentials token source +# --------------------------------------------------------------------------- + + +class ClientCredentialsTokenSource: + """Fetches tokens from an IdP using the OAuth 2.0 client_credentials grant. + + Tokens are cached internally and refreshed automatically when expired. + + Example:: + + from openfaas import TokenAuth, ClientCredentialsTokenSource + + ts = ClientCredentialsTokenSource( + client_id="my-app", + client_secret="secret", + token_url="https://idp.example.com/realms/master/protocol/openid-connect/token", + scope="openid", + ) + auth = TokenAuth( + token_url="https://gateway.example.com/oauth/token", + token_source=ts, + ) + """ + + def __init__( + self, + client_id: str, + client_secret: str, + token_url: str, + scope: str = "", + grant_type: str = "client_credentials", + audience: str = "", + *, + timeout: float = 30.0, + http_client: requests.Session | None = None, + ) -> None: + self._client_id = client_id + self._client_secret = client_secret + self._token_url = token_url + self._scope = scope + self._grant_type = grant_type + self._audience = audience + self._timeout = timeout + self._http_client = http_client + self._token: Token | None = None + self._lock = threading.Lock() + + def _build_data(self) -> dict[str, str]: + data: dict[str, str] = { + "client_id": self._client_id, + "client_secret": self._client_secret, + "grant_type": self._grant_type, + } + if self._scope: + data["scope"] = self._scope + if self._audience: + data["audience"] = self._audience + return data + + def token(self) -> str: + """Return a valid access token, fetching a new one if necessary.""" + with self._lock: + if self._token is None or self._token.is_expired(): + self._token = self._fetch() + return self._token.id_token + + def _fetch(self) -> Token: + _owns_session = self._http_client is None + session = self._http_client or requests.Session() + try: + response = session.post(self._token_url, data=self._build_data(), timeout=self._timeout) + finally: + if _owns_session: + session.close() + if not response.ok: + raise RuntimeError( + f"Failed to obtain client credentials token: HTTP {response.status_code} — {response.text}" + ) + return parse_token_response(response.json()) + + def __repr__(self) -> str: + return f"ClientCredentialsTokenSource(client_id={self._client_id!r}, token_url={self._token_url!r})" diff --git a/openfaas/builder/__init__.py b/openfaas/builder/__init__.py new file mode 100644 index 0000000..5aa6199 --- /dev/null +++ b/openfaas/builder/__init__.py @@ -0,0 +1,54 @@ +""" +OpenFaaS Function Builder subpackage. + +Provides a client and helpers for interacting with the OpenFaaS Pro Function +Builder API. + +Quickstart:: + + from openfaas.builder import FunctionBuilder, BuildConfig, make_tar + + config = BuildConfig( + image="ttl.sh/hello-world:1h", + platforms=["linux/amd64"], + ) + make_tar("/tmp/req.tar", "./build/hello-world", config) + + builder = FunctionBuilder("http://127.0.0.1:8081", hmac_secret="s3cr3t") + + # Non-streaming — wait for the final result. + result = builder.build("/tmp/req.tar") + print(result.status, result.image) + + # Streaming — receive log lines as they arrive. + for result in builder.build_stream("/tmp/req.tar"): + for line in result.log: + print(line) +""" + +from openfaas.builder.client import FunctionBuilder +from openfaas.builder.models import ( + BUILD_FAILED, + BUILD_IN_PROGRESS, + BUILD_SUCCESS, + BUILDER_CONFIG_FILE_NAME, + BuildConfig, + BuildResult, +) +from openfaas.builder.tar import create_build_context, make_tar + +__all__ = [ + # Client + "FunctionBuilder", + # Models + "BuildConfig", + "BuildResult", + # Constants + "BUILDER_CONFIG_FILE_NAME", + "BUILD_IN_PROGRESS", + "BUILD_SUCCESS", + "BUILD_FAILED", + # Tar helpers + "make_tar", + "create_build_context", +] diff --git a/openfaas/builder/client.py b/openfaas/builder/client.py new file mode 100644 index 0000000..7b3c89f --- /dev/null +++ b/openfaas/builder/client.py @@ -0,0 +1,137 @@ +""" +HTTP client for the OpenFaaS Function Builder API. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +from collections.abc import Iterator + +import requests + +from openfaas._transport import build_session +from openfaas.builder.models import BuildResult + + +class FunctionBuilder: + """Client for the OpenFaaS Function Builder API. + + Sends a tar archive containing a build context and a ``BuildConfig`` to + the builder's ``/build`` endpoint and returns one or more + :class:`~openfaas.builder.models.BuildResult` objects. + + Args: + url: Base URL of the builder, e.g. ``"http://127.0.0.1:8081"``. + hmac_secret: Optional shared secret used to sign each request with an + HMAC-SHA256 digest. When provided the ``X-Build-Signature: + sha256=`` header is added to every request. + http_client: Optional pre-configured :class:`requests.Session`. + Defaults to a session with the SDK ``User-Agent`` header set. + + Example:: + + from openfaas.builder import FunctionBuilder, BuildConfig, make_tar + + config = BuildConfig(image="ttl.sh/hello-world:1h") + make_tar("/tmp/req.tar", "./build/hello-world", config) + + builder = FunctionBuilder("http://127.0.0.1:8081", hmac_secret="s3cr3t") + result = builder.build("/tmp/req.tar") + print(result.status, result.image) + """ + + def __init__( + self, + url: str, + *, + hmac_secret: str | None = None, + http_client: requests.Session | None = None, + ) -> None: + self._url = url.rstrip("/") + self._hmac_secret = hmac_secret + self._http = http_client or build_session() + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def build(self, tar_path: str) -> BuildResult: + """Send the tar archive to the builder and return the final result. + + The call blocks until the builder responds with a complete + :class:`~openfaas.builder.models.BuildResult`. + + Args: + tar_path: Path to the tar archive produced by + :func:`~openfaas.builder.tar.make_tar`. + + Returns: + A :class:`~openfaas.builder.models.BuildResult` describing the + outcome of the build. + + Raises: + requests.HTTPError: If the builder returns a non-2xx status code. + """ + response = self._post(tar_path, stream=False) + response.raise_for_status() + return BuildResult.from_dict(response.json()) + + def build_stream(self, tar_path: str) -> Iterator[BuildResult]: + """Send the tar archive to the builder and stream build results. + + Yields :class:`~openfaas.builder.models.BuildResult` objects as + NDJSON lines arrive from the builder. The underlying connection is + closed once the iterator is exhausted or if the caller breaks early. + + Args: + tar_path: Path to the tar archive produced by + :func:`~openfaas.builder.tar.make_tar`. + + Yields: + :class:`~openfaas.builder.models.BuildResult` for each NDJSON + line received from the builder. + + Raises: + requests.HTTPError: If the builder returns a non-2xx status code. + """ + response = self._post(tar_path, stream=True) + response.raise_for_status() + try: + for line in response.iter_lines(): + if not line: + continue + data = json.loads(line) + yield BuildResult.from_dict(data) + finally: + response.close() + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _post(self, tar_path: str, *, stream: bool) -> requests.Response: + """Read the tar file, sign it if configured, and POST to ``/build``.""" + with open(tar_path, "rb") as fh: + body = fh.read() + + headers: dict[str, str] = { + "Content-Type": "application/octet-stream", + } + if stream: + headers["Accept"] = "application/x-ndjson" + if self._hmac_secret: + digest = hmac.new( + self._hmac_secret.encode(), + body, + hashlib.sha256, + ).hexdigest() + headers["X-Build-Signature"] = f"sha256={digest}" + + return self._http.post( + f"{self._url}/build", + data=body, + headers=headers, + stream=stream, + ) diff --git a/openfaas/builder/models.py b/openfaas/builder/models.py new file mode 100644 index 0000000..0126e93 --- /dev/null +++ b/openfaas/builder/models.py @@ -0,0 +1,76 @@ +""" +Data models for the OpenFaaS Function Builder API. +""" + +from dataclasses import dataclass, field + +# Name of the build config file embedded in the tar archive. +BUILDER_CONFIG_FILE_NAME = "com.openfaas.docker.config" + +# Build status constants returned by the builder API. +BUILD_IN_PROGRESS = "in_progress" +BUILD_SUCCESS = "success" +BUILD_FAILED = "failed" + + +@dataclass +class BuildConfig: + """Configuration for a function build, serialised into the tar archive as + ``com.openfaas.docker.config``. + + Args: + image: Fully-qualified Docker image name to build and push, e.g. + ``ttl.sh/hello-world:1h``. + build_args: Optional Docker build arguments passed to the builder. + platforms: Target platforms, e.g. ``["linux/amd64", "linux/arm64"]``. + Leave empty to use the builder default. + skip_push: When ``True`` the image is built but not pushed to the + registry. + """ + + image: str + build_args: dict[str, str] = field(default_factory=dict) # pyright: ignore[reportUnknownVariableType] + platforms: list[str] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType] + skip_push: bool = False + + def to_dict(self) -> dict[str, object]: + """Return a JSON-serialisable dict using the wire field names expected + by the builder API.""" + d: dict[str, object] = {"image": self.image} + if self.build_args: + d["buildArgs"] = self.build_args + if self.platforms: + d["platforms"] = self.platforms + if self.skip_push: + d["skipPush"] = self.skip_push + return d + + +@dataclass +class BuildResult: + """Result returned by the builder API, for both non-streaming and streaming + responses. + + Args: + log: Ordered list of log lines produced during the build. + image: Fully-qualified image name that was built. + status: One of :data:`BUILD_IN_PROGRESS`, :data:`BUILD_SUCCESS`, or + :data:`BUILD_FAILED`. + error: Human-readable error message, present only when the build + failed. + """ + + log: list[str] + image: str + status: str + error: str = "" + + @classmethod + def from_dict(cls, data: dict[str, object]) -> "BuildResult": + """Construct a :class:`BuildResult` from a parsed JSON dict.""" + return cls( + log=list(data.get("log") or []), # type: ignore[arg-type] + image=str(data.get("image") or ""), + status=str(data.get("status") or ""), + error=str(data.get("error") or ""), + ) diff --git a/openfaas/builder/tar.py b/openfaas/builder/tar.py new file mode 100644 index 0000000..ec3f1bb --- /dev/null +++ b/openfaas/builder/tar.py @@ -0,0 +1,202 @@ +""" +Tar archive helpers for the OpenFaaS Function Builder. + +:func:`make_tar` packs an on-disk build context into a tar file ready to POST +to the builder API. + +:func:`create_build_context` assembles that on-disk context from a template +directory and a function handler directory. +""" + +from __future__ import annotations + +import json +import os +import shutil +import tarfile +from pathlib import Path + +from openfaas.builder.models import BUILDER_CONFIG_FILE_NAME, BuildConfig + + +def make_tar(tar_path: str, context: str, build_config: BuildConfig) -> None: + """Create a tar archive at *tar_path* containing the build context. + + The archive contains: + + * The ``context`` directory tree, rooted as ``context/`` inside the tar. + * The JSON-serialised *build_config* as ``com.openfaas.docker.config`` at + the tar root. + + Args: + tar_path: Destination path for the ``.tar`` file. + context: Path to the build context directory on disk. + build_config: Build configuration to embed in the archive. + """ + config_bytes = json.dumps(build_config.to_dict()).encode() + + with tarfile.open(tar_path, "w") as tar: + # Add the build config file. + import io + + info = tarfile.TarInfo(name=BUILDER_CONFIG_FILE_NAME) + info.size = len(config_bytes) + info.mode = 0o664 + tar.addfile(info, io.BytesIO(config_bytes)) + + # Add the context directory, rooted as "context/" in the archive. + context_path = Path(context).resolve() + tar.add(str(context_path), arcname="context") + + +def create_build_context( + function_name: str, + handler: str, + language: str, + copy_extra_paths: list[str] | None = None, + *, + build_dir: str = "./build", + template_dir: str = "./template", + handler_overlay: str = "function", +) -> str: + """Prepare a Docker build context on disk and return its path. + + The context is assembled as follows: + + 1. ``/`` is cleared and re-created. + 2. For non-``dockerfile`` languages the template from + ``/`` is copied into the context. + 3. The function *handler* directory is overlaid onto + ``//``, skipping any ``build/`` or + ``template/`` sub-directories inside the handler. + 4. Any paths listed in *copy_extra_paths* are copied into the context root. + Each path must be relative and resolve within the current directory. + + Args: + function_name: Name used for the build context subdirectory. + handler: Path to the function handler directory. + language: Template language, e.g. ``"node20"``. Pass + ``"dockerfile"`` to skip template copying. + copy_extra_paths: Additional paths to copy into the context root. + build_dir: Root directory for build contexts. Defaults to + ``"./build"``. + template_dir: Directory containing language templates. Defaults to + ``"./template"``. + handler_overlay: Sub-path within the context where the handler is + placed. Defaults to ``"function"``. + + Returns: + Absolute path to the assembled build context directory. + + Raises: + FileNotFoundError: If the template directory for *language* does not + exist (and *language* is not ``"dockerfile"``). + ValueError: If *function_name* resolves outside *build_dir*, if + *language* resolves outside *template_dir*, if *handler_overlay* + resolves outside the context directory, or if a path in + *copy_extra_paths* resolves outside the current working directory. + """ + abs_build_dir = str(Path(build_dir).resolve()) + context_path = Path(build_dir) / function_name + context_path = context_path.resolve() + try: + _path_in_scope(str(context_path), abs_build_dir) + except ValueError: + raise ValueError( + f"function_name must not contain path separators or traversal sequences: {function_name!r}" + ) from None + + # Clear and re-create the context directory. + if context_path.exists(): + shutil.rmtree(context_path) + context_path.mkdir(parents=True, exist_ok=True) + + # Copy the template into the context (skip for raw dockerfile builds). + if language.lower() != "dockerfile": + template_src = Path(template_dir) / language + abs_template_dir = str(Path(template_dir).resolve()) + try: + _path_in_scope(str(template_src.resolve()), abs_template_dir) + except ValueError: + raise ValueError( + f"language must not contain path separators or traversal sequences: {language!r}" + ) from None + if not template_src.exists(): + raise FileNotFoundError(f"Template directory not found: {template_src}") + _copy_tree(str(template_src), str(context_path)) + + # Overlay the handler directory, skipping build/ and template/ subdirs. + overlay_dest = context_path / handler_overlay + try: + _path_in_scope(str(overlay_dest.resolve()), str(context_path)) + except ValueError: + raise ValueError( + f"handler_overlay must not contain path separators or traversal sequences: {handler_overlay!r}" + ) from None + overlay_dest.mkdir(parents=True, exist_ok=True) + handler_path = Path(handler).resolve() + _copy_handler(str(handler_path), str(overlay_dest)) + + # Copy any extra paths into the context root. + for extra in copy_extra_paths or []: + abs_extra = _path_in_scope(extra, str(Path(".").resolve())) + dest = context_path / Path(extra).name + if Path(abs_extra).is_dir(): + _copy_tree(abs_extra, str(dest)) + else: + shutil.copy2(abs_extra, str(dest)) + + return str(context_path) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _copy_tree(src: str, dst: str) -> None: + """Recursively copy *src* directory into *dst*, creating *dst* if needed.""" + src_path = Path(src) + dst_path = Path(dst) + dst_path.mkdir(parents=True, exist_ok=True) + for item in src_path.iterdir(): + s = src_path / item.name + d = dst_path / item.name + if s.is_dir(): + _copy_tree(str(s), str(d)) + else: + shutil.copy2(str(s), str(d)) + + +def _copy_handler(src: str, dst: str) -> None: + """Copy handler directory into *dst*, skipping ``build/`` and ``template/`` + subdirectories.""" + _SKIP = {"build", "template"} + src_path = Path(src) + dst_path = Path(dst) + dst_path.mkdir(parents=True, exist_ok=True) + for item in src_path.iterdir(): + if item.is_dir() and item.name in _SKIP: + continue + s = src_path / item.name + d = dst_path / item.name + if s.is_dir(): + _copy_tree(str(s), str(d)) + else: + shutil.copy2(str(s), str(d)) + + +def _path_in_scope(path: str, scope: str) -> str: + """Return the absolute path for *path* and verify it is within *scope*. + + Raises: + ValueError: If the resolved path equals *scope* or falls outside it. + """ + abs_path = str(Path(path).resolve()) + abs_scope = str(Path(scope).resolve()) + + if abs_path == abs_scope: + raise ValueError(f"Path must not be the scope root itself: {path!r}") + if not abs_path.startswith(abs_scope + os.sep): + raise ValueError(f"Path {path!r} resolves outside the allowed scope {scope!r}") + return abs_path diff --git a/openfaas/client.py b/openfaas/client.py new file mode 100644 index 0000000..8dd25e5 --- /dev/null +++ b/openfaas/client.py @@ -0,0 +1,574 @@ +""" +OpenFaaS Python SDK — HTTP client. + +Provides a synchronous client backed by :class:`requests.Session`: + +* ``Client`` — synchronous, backed by ``requests.Session`` + +Example — sync with Basic auth:: + + from openfaas import Client, BasicAuth + + client = Client( + gateway_url="https://gateway.example.com", + auth=BasicAuth("admin", "secret"), + ) + functions = client.get_functions("openfaas-fn") + +Example — sync with IAM auth (Kubernetes workload):: + + from openfaas import Client, TokenAuth, ServiceAccountTokenSource + + auth = TokenAuth( + token_url="https://gateway.example.com/oauth/token", + token_source=ServiceAccountTokenSource(), + ) + client = Client("https://gateway.example.com", auth=auth) +""" + +from __future__ import annotations + +from collections.abc import Iterator +from datetime import datetime +from typing import Any + +import requests +import requests.auth + +from openfaas._transport import build_session +from openfaas.auth import TokenSource +from openfaas.exceptions import ( + APIConnectionError, + ForbiddenError, + NotFoundError, + UnauthorizedError, + UnexpectedStatusError, +) +from openfaas.exchange import exchange_id_token +from openfaas.models import ( + FunctionDeployment, + FunctionNamespace, + FunctionStatus, + LogMessage, + Secret, + SystemInfo, +) +from openfaas.token_cache import TokenCache + +# Label required by OpenFaaS Pro namespace management, injected on every +# deploy and update to indicate a namespace can be used by OpenFaaS. +_OPENFAAS_LABEL = "openfaas" + + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + + +def _raise_for_status(response: requests.Response) -> None: + """Raise the appropriate SDK exception for non-2xx responses.""" + if response.ok: + return + body = response.text + if response.status_code == 404: + raise NotFoundError(f"Not found: {body}", response=response) + if response.status_code == 401: + raise UnauthorizedError(f"Unauthorized: {body}", response=response) + if response.status_code == 403: + raise ForbiddenError(f"Forbidden: {body}", response=response) + raise UnexpectedStatusError( + f"Unexpected status {response.status_code}: {body}", + response=response, + ) + + +def _inject_openfaas_labels(spec: FunctionNamespace) -> dict[str, Any]: + """Return the API dict for a namespace, ensuring the openfaas label is set.""" + data = spec.to_api_dict() + data.setdefault("labels", {})[_OPENFAAS_LABEL] = "1" + data.setdefault("annotations", {})[_OPENFAAS_LABEL] = "1" + return data + + +def _parse_log_line(line: str) -> LogMessage | None: + line = line.strip() + if not line: + return None + try: + return LogMessage.model_validate_json(line) + except Exception: + return None + + +def _fn_cache_key(name: str, namespace: str) -> str: + return f"{name}.{namespace}" + + +class _BearerAuth(requests.auth.AuthBase): + """Sets a static Bearer token on the Authorization header.""" + + def __init__(self, token: str) -> None: + self._token = token + + def __call__(self, r: requests.PreparedRequest) -> requests.PreparedRequest: + r.headers["Authorization"] = f"Bearer {self._token}" + return r + + +# --------------------------------------------------------------------------- +# Synchronous client +# --------------------------------------------------------------------------- + + +class Client: + """Synchronous OpenFaaS gateway client. + + Args: + gateway_url: Base URL of the OpenFaaS gateway, e.g. + ``"https://gateway.example.com"``. + auth: Authentication strategy. Pass a :class:`~openfaas.BasicAuth`, + :class:`~openfaas.TokenAuth`, or any :class:`requests.auth.AuthBase` + subclass. + timeout: Default request timeout in seconds. Defaults to ``30``. + function_token_source: Optional :class:`~openfaas.auth.TokenSource` + used to obtain per-function scoped tokens for function invocation. + When *auth* implements the :class:`~openfaas.auth.TokenSource` + protocol (e.g. :class:`~openfaas.TokenAuth`), it is automatically + used as the function token source if this is not set explicitly. + token_cache: Optional :class:`~openfaas.token_cache.TokenCache` for + caching per-function scoped tokens across invocations. + http_client: Supply a pre-configured :class:`requests.Session` to + override transport, proxies, or other low-level settings. + """ + + def __init__( + self, + gateway_url: str, + auth: requests.auth.AuthBase | None = None, + *, + timeout: float = 30.0, + function_token_source: TokenSource | None = None, + token_cache: TokenCache | None = None, + http_client: requests.Session | None = None, + ) -> None: + self._gateway_url = gateway_url.rstrip("/") + self._auth = auth + self._timeout = timeout + self._http = http_client or build_session(timeout=timeout) + self._token_cache = token_cache + + # Auto-wire: if auth implements TokenSource (e.g. TokenAuth), use it + # as the function token source when none is provided explicitly. + if function_token_source is not None: + self._function_token_source: TokenSource | None = function_token_source + elif isinstance(auth, TokenSource): + self._function_token_source = auth + else: + self._function_token_source = None + + # ------------------------------------------------------------------ + # Context manager support + # ------------------------------------------------------------------ + + def __enter__(self) -> Client: + return self + + def __exit__(self, *_: object) -> None: + self.close() + + def close(self) -> None: + """Close the underlying HTTP session.""" + self._http.close() + + # ------------------------------------------------------------------ + # Internal request helper + # ------------------------------------------------------------------ + + def _request( + self, + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + json: Any = None, + timeout: float | None = None, + stream: bool = False, + ) -> requests.Response: + url = f"{self._gateway_url}{path}" + try: + return self._http.request( + method, + url, + params=params, + json=json, + auth=self._auth, + timeout=timeout if timeout is not None else self._timeout, + stream=stream, + ) + except requests.ConnectionError as exc: + raise APIConnectionError() from exc + except requests.Timeout as exc: + raise APIConnectionError("Request to the OpenFaaS gateway timed out") from exc + + # ------------------------------------------------------------------ + # System + # ------------------------------------------------------------------ + + def get_info(self) -> SystemInfo: + """Return gateway system information.""" + response = self._request("GET", "/system/info") + _raise_for_status(response) + return SystemInfo.model_validate(response.json()) + + # ------------------------------------------------------------------ + # Namespaces + # ------------------------------------------------------------------ + + def get_namespaces(self) -> list[str]: + """Return all namespaces available on the gateway.""" + response = self._request("GET", "/system/namespaces") + _raise_for_status(response) + return response.json() + + def get_namespace(self, namespace: str) -> FunctionNamespace: + """Return details for a single namespace.""" + response = self._request("GET", f"/system/namespace/{namespace}") + _raise_for_status(response) + return FunctionNamespace.model_validate(response.json()) + + def create_namespace(self, spec: FunctionNamespace) -> int: + """Create a namespace. Returns the HTTP status code.""" + response = self._request("POST", "/system/namespace/", json=_inject_openfaas_labels(spec)) + _raise_for_status(response) + return response.status_code + + def update_namespace(self, spec: FunctionNamespace) -> int: + """Update an existing namespace. Returns the HTTP status code.""" + response = self._request("PUT", f"/system/namespace/{spec.name}", json=_inject_openfaas_labels(spec)) + _raise_for_status(response) + return response.status_code + + def delete_namespace(self, namespace: str) -> None: + """Delete a namespace.""" + body = FunctionNamespace(name=namespace) + response = self._request("DELETE", f"/system/namespace/{namespace}", json=_inject_openfaas_labels(body)) + _raise_for_status(response) + + # ------------------------------------------------------------------ + # Functions + # ------------------------------------------------------------------ + + def get_functions(self, namespace: str | None = None) -> list[FunctionStatus]: + """Return all functions, optionally filtered by namespace.""" + params = {"namespace": namespace} if namespace else None + response = self._request("GET", "/system/functions", params=params) + _raise_for_status(response) + return [FunctionStatus.model_validate(f) for f in response.json()] + + def get_function(self, name: str, namespace: str | None = None) -> FunctionStatus: + """Return details for a single function.""" + params = {"namespace": namespace} if namespace else None + response = self._request("GET", f"/system/function/{name}", params=params) + _raise_for_status(response) + return FunctionStatus.model_validate(response.json()) + + def deploy(self, spec: FunctionDeployment) -> int: + """Deploy a new function. Returns the HTTP status code.""" + response = self._request("POST", "/system/functions", json=spec.to_api_dict()) + _raise_for_status(response) + return response.status_code + + def update(self, spec: FunctionDeployment) -> int: + """Update an existing function. Returns the HTTP status code.""" + response = self._request("PUT", "/system/functions", json=spec.to_api_dict()) + _raise_for_status(response) + return response.status_code + + def delete_function(self, name: str, namespace: str | None = None) -> None: + """Delete a function.""" + body: dict[str, Any] = {"functionName": name} + if namespace: + body["namespace"] = namespace + response = self._request("DELETE", "/system/functions", json=body) + _raise_for_status(response) + + def scale_function(self, name: str, replicas: int, namespace: str | None = None) -> None: + """Scale a function to the specified number of replicas.""" + body: dict[str, Any] = {"serviceName": name, "replicas": replicas} + if namespace: + body["namespace"] = namespace + response = self._request("POST", f"/system/scale-function/{name}", json=body) + _raise_for_status(response) + + # ------------------------------------------------------------------ + # Secrets + # ------------------------------------------------------------------ + + def get_secrets(self, namespace: str | None = None) -> list[Secret]: + """Return all secrets, optionally filtered by namespace.""" + params = {"namespace": namespace} if namespace else None + response = self._request("GET", "/system/secrets", params=params) + _raise_for_status(response) + return [Secret.model_validate(s) for s in response.json()] + + def create_secret(self, spec: Secret) -> int: + """Create a secret. Returns the HTTP status code.""" + response = self._request("POST", "/system/secrets", json=spec.to_api_dict()) + _raise_for_status(response) + return response.status_code + + def update_secret(self, spec: Secret) -> int: + """Update an existing secret. Returns the HTTP status code.""" + response = self._request("PUT", "/system/secrets", json=spec.to_api_dict()) + _raise_for_status(response) + return response.status_code + + def delete_secret(self, name: str, namespace: str | None = None) -> None: + """Delete a secret.""" + body: dict[str, Any] = {"name": name} + if namespace: + body["namespace"] = namespace + response = self._request("DELETE", "/system/secrets", json=body) + _raise_for_status(response) + + # ------------------------------------------------------------------ + # Logs + # ------------------------------------------------------------------ + + def get_logs( + self, + name: str, + namespace: str | None = None, + *, + tail: int | None = None, + follow: bool = False, + since: datetime | None = None, + ) -> Iterator[LogMessage]: + """Stream log messages for a function. + + Yields :class:`~openfaas.LogMessage` instances parsed from the + NDJSON response. The iterator blocks until the server closes the + connection (``follow=False``) or the caller breaks out of the loop. + + Args: + name: Function name. + namespace: Function namespace. + tail: Maximum number of recent log lines to return. + follow: If ``True``, keep the connection open and stream new log + lines as they arrive. + since: Return only log lines after this timestamp. + """ + params: dict[str, Any] = {"name": name} + if namespace: + params["namespace"] = namespace + if tail is not None: + params["tail"] = tail + params["follow"] = "1" if follow else "0" + if since is not None: + params["since"] = since.isoformat() + + response = self._request("GET", "/system/logs", params=params, stream=True) + _raise_for_status(response) + try: + for line in response.iter_lines(): + if isinstance(line, bytes): + line = line.decode() + msg = _parse_log_line(line) + if msg is not None: + yield msg + finally: + response.close() + + # ------------------------------------------------------------------ + # Function invocation + # ------------------------------------------------------------------ + + def invoke_function( + self, + name: str, + namespace: str = "openfaas-fn", + *, + method: str, + payload: bytes | str | None = None, + headers: dict[str, str] | None = None, + query_params: dict[str, str] | None = None, + use_function_auth: bool = False, + ) -> requests.Response: + """Invoke a deployed function and return the raw response. + + No exception is raised for non-2xx responses — function responses are + application-level and the caller decides how to interpret them. + + Args: + name: Function name. + namespace: Function namespace. Defaults to ``"openfaas-fn"``. + method: HTTP method, e.g. ``"GET"`` or ``"POST"``. + payload: Request body. Accepts :class:`bytes` or :class:`str` + (UTF-8 encoded automatically). + headers: Additional request headers merged with any auth headers. + query_params: Query string parameters. + use_function_auth: If ``True``, obtain a per-function scoped token + via :meth:`get_function_token` and attach it as + ``Authorization: Bearer ``, overriding any gateway-level + auth header. Requires a ``function_token_source`` to be + configured on the client. + + Returns: + The raw :class:`requests.Response` from the function. + + Raises: + :exc:`~openfaas.APIConnectionError`: On network or timeout errors. + """ + url = f"{self._gateway_url}/function/{name}.{namespace}" + return self._invoke( + method=method, + url=url, + name=name, + namespace=namespace, + payload=payload, + headers=headers, + query_params=query_params, + use_function_auth=use_function_auth, + ) + + def invoke_function_async( + self, + name: str, + namespace: str = "openfaas-fn", + *, + payload: bytes | str | None = None, + headers: dict[str, str] | None = None, + query_params: dict[str, str] | None = None, + callback_url: str | None = None, + use_function_auth: bool = False, + ) -> requests.Response: + """Queue a function invocation and return the gateway's 202 response. + + The gateway queues the invocation and responds immediately with + ``202 Accepted``. The function result is not returned synchronously. + + Args: + name: Function name. + namespace: Function namespace. Defaults to ``"openfaas-fn"``. + payload: Request body. Accepts :class:`bytes` or :class:`str` + (UTF-8 encoded automatically). + headers: Additional request headers merged with any auth headers. + query_params: Query string parameters. + callback_url: If provided, the gateway will ``POST`` the function + result to this URL once the invocation completes. + use_function_auth: If ``True``, obtain a per-function scoped token + via :meth:`get_function_token` and attach it as + ``Authorization: Bearer ``, overriding any gateway-level + auth header. Requires a ``function_token_source`` to be + configured on the client. + + Returns: + The ``202 Accepted`` :class:`requests.Response` from the gateway. + + Raises: + :exc:`~openfaas.APIConnectionError`: On network or timeout errors. + """ + url = f"{self._gateway_url}/async-function/{name}.{namespace}" + merged_headers: dict[str, str] = dict(headers) if headers else {} + if callback_url is not None: + merged_headers["X-Callback-Url"] = callback_url + return self._invoke( + method="POST", + url=url, + name=name, + namespace=namespace, + payload=payload, + headers=merged_headers, + query_params=query_params, + use_function_auth=use_function_auth, + ) + + def _invoke( + self, + *, + method: str, + url: str, + name: str, + namespace: str, + payload: bytes | str | None, + headers: dict[str, str] | None, + query_params: dict[str, str] | None, + use_function_auth: bool, + ) -> requests.Response: + merged_headers: dict[str, str] = dict(headers) if headers else {} + + data: bytes | None = None + if payload is not None: + data = payload.encode() if isinstance(payload, str) else payload + + if use_function_auth: + fn_token = self.get_function_token(name, namespace) + auth: requests.auth.AuthBase | None = _BearerAuth(fn_token) + else: + auth = None + + try: + return self._http.request( + method, + url, + data=data, + headers=merged_headers, + params=query_params, + auth=auth, + timeout=self._timeout, + ) + except requests.ConnectionError as exc: + raise APIConnectionError() from exc + except requests.Timeout as exc: + raise APIConnectionError("Request to the OpenFaaS gateway timed out") from exc + + # ------------------------------------------------------------------ + # Function token exchange (for IAM-protected function invocation) + # ------------------------------------------------------------------ + + def get_function_token(self, name: str, namespace: str) -> str: + """Return a scoped access token for invoking a specific function. + + Exchanges the client's own identity token (from + ``function_token_source``) for a token scoped to the given function. + The result is cached in ``token_cache`` if one was provided. + + This is called automatically by ``invoke_function`` when IAM auth is + enabled, but can also be called directly. + + Args: + name: Function name. + namespace: Function namespace. + + Returns: + A raw JWT string scoped to the target function. + + Raises: + :exc:`RuntimeError`: If no ``function_token_source`` is configured. + """ + if self._function_token_source is None: + raise RuntimeError( + "No function_token_source configured. " + "Pass a TokenAuth as auth, or set function_token_source explicitly." + ) + + cache_key = _fn_cache_key(name, namespace) + if self._token_cache is not None: + cached = self._token_cache.get(cache_key) + if cached is not None: + return cached.id_token + + id_token = self._function_token_source.token() + token_url = f"{self._gateway_url}/oauth/token" + token = exchange_id_token( + token_url, + id_token, + scope=["function"], + audience=[f"{namespace}:{name}"], + http_client=self._http, + ) + + if self._token_cache is not None: + self._token_cache.set(cache_key, token) + + return token.id_token diff --git a/openfaas/exceptions.py b/openfaas/exceptions.py new file mode 100644 index 0000000..43d3dab --- /dev/null +++ b/openfaas/exceptions.py @@ -0,0 +1,56 @@ +""" +Exceptions for the OpenFaaS Python SDK. + +All exceptions inherit from OpenFaaSError, allowing callers to catch broadly +or narrowly as needed: + + try: + client.get_function("my-fn", "openfaas-fn") + except NotFoundError: + ... + except OpenFaaSError: + ... +""" + +from __future__ import annotations + +import requests + + +class OpenFaaSError(Exception): + """Base class for all OpenFaaS SDK exceptions.""" + + +class APIConnectionError(OpenFaaSError): + """Raised when the SDK cannot reach the OpenFaaS gateway.""" + + def __init__(self, message: str = "Could not connect to the OpenFaaS gateway") -> None: + super().__init__(message) + + +class APIStatusError(OpenFaaSError): + """Raised when the gateway returns a non-successful HTTP status code.""" + + status_code: int + response: requests.Response + + def __init__(self, message: str, *, response: requests.Response) -> None: + super().__init__(message) + self.status_code = response.status_code + self.response = response + + +class NotFoundError(APIStatusError): + """Raised on HTTP 404 responses.""" + + +class UnauthorizedError(APIStatusError): + """Raised on HTTP 401 responses.""" + + +class ForbiddenError(APIStatusError): + """Raised on HTTP 403 responses.""" + + +class UnexpectedStatusError(APIStatusError): + """Raised when an unexpected HTTP status code is returned.""" diff --git a/openfaas/exchange.py b/openfaas/exchange.py new file mode 100644 index 0000000..7b29372 --- /dev/null +++ b/openfaas/exchange.py @@ -0,0 +1,114 @@ +""" +Token exchange for the OpenFaaS Python SDK. + +Implements the OAuth 2.0 Token Exchange grant +(RFC 8693 / ``urn:ietf:params:oauth:grant-type:token-exchange``) against the +OpenFaaS gateway's ``/oauth/token`` endpoint. +""" + +from __future__ import annotations + +import logging +import os +import re + +import requests + +from openfaas.token import OAuthError, Token, parse_token_response + +logger = logging.getLogger("openfaas") + +_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange" +_SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id_token" +_USER_AGENT = "openfaas-python-sdk" + +_AUTH_REDACT_RE = re.compile(r"(Basic|Bearer)\s+\S+", re.IGNORECASE) + + +def _is_debug() -> bool: + return os.environ.get("FAAS_DEBUG", "").strip() == "1" + + +def _redact_auth(value: str) -> str: + return _AUTH_REDACT_RE.sub(r"\1 [REDACTED]", value) + + +def exchange_id_token( + token_url: str, + raw_id_token: str, + *, + audience: list[str] | None = None, + scope: list[str] | None = None, + timeout: float = 30.0, + http_client: requests.Session | None = None, +) -> Token: + """Exchange an upstream identity token for an OpenFaaS gateway token. + + Performs the OAuth 2.0 Token Exchange grant against the OpenFaaS gateway + ``/oauth/token`` endpoint. + + Args: + token_url: Full URL of the token endpoint, + e.g. ``"https://gateway.example.com/oauth/token"``. + raw_id_token: The upstream JWT identity token to exchange. + audience: Optional list of audiences to request. For function + invocation this should be + ``[":"]``. + scope: Optional list of scopes to request, e.g. + ``["function"]``. + http_client: Optional :class:`requests.Session` to use for the + request. Defaults to a short-lived throwaway session. + timeout: Request timeout in seconds. Defaults to ``30.0``. + + Returns: + A :class:`~openfaas.token.Token` containing the OpenFaaS JWT. + + Raises: + :class:`~openfaas.token.OAuthError`: When the endpoint returns HTTP 400 + with an OAuth error JSON body. + :exc:`requests.HTTPError`: For other non-2xx responses. + """ + # Build form data. audience is repeated for each value; requests handles + # lists correctly when passed via the ``data`` parameter as a list of + # (key, value) tuples. + data: list[tuple[str, str]] = [ + ("grant_type", _GRANT_TYPE), + ("subject_token_type", _SUBJECT_TOKEN_TYPE), + ("subject_token", raw_id_token), + ] + if audience: + for aud in audience: + data.append(("audience", aud)) + if scope: + data.append(("scope", " ".join(scope))) + + headers = {"User-Agent": _USER_AGENT} + + if _is_debug(): + redacted = [(k, _redact_auth(v) if k == "subject_token" else v) for k, v in data] + logger.debug("→ POST %s data=%s", token_url, redacted) + + _owns_session = http_client is None + session = http_client or requests.Session() + + try: + response = session.post(token_url, data=data, headers=headers, timeout=timeout) + + if _is_debug(): + logger.debug("← %s %s", response.status_code, token_url) + + if response.status_code == 400: + try: + err = response.json() + raise OAuthError( + err.get("error", "unknown_error"), + err.get("error_description", ""), + ) + except (ValueError, KeyError): + raise OAuthError("unknown_error", response.text) from None + + response.raise_for_status() + return parse_token_response(response.json()) + finally: + if _owns_session: + session.close() diff --git a/openfaas/models.py b/openfaas/models.py new file mode 100644 index 0000000..e15803c --- /dev/null +++ b/openfaas/models.py @@ -0,0 +1,158 @@ +""" +Pydantic models for the OpenFaaS Python SDK. + +These models map directly to the OpenFaaS REST API request and response +bodies, following the same schema as the faas-provider types used by the +OpenFaaS gateway. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field + +# --------------------------------------------------------------------------- +# Shared / primitive types +# --------------------------------------------------------------------------- + + +class VersionInfo(BaseModel): + commit_message: str = Field(default="", alias="commit_message") + sha: str = Field(default="") + release: str = Field(default="") + + model_config = {"populate_by_name": True} + + +# --------------------------------------------------------------------------- +# System info +# --------------------------------------------------------------------------- + + +class Provider(BaseModel): + provider: str = Field(default="") + version: VersionInfo = Field(default_factory=VersionInfo) + orchestration: str = Field(default="") + + +class SystemInfo(BaseModel): + arch: str = Field(default="") + provider: Provider = Field(default_factory=Provider) + version: VersionInfo = Field(default_factory=VersionInfo) + + +# --------------------------------------------------------------------------- +# Function resources +# --------------------------------------------------------------------------- + + +class FunctionResources(BaseModel): + memory: str | None = None + cpu: str | None = None + + +# --------------------------------------------------------------------------- +# Function deployment (create / update request body) +# --------------------------------------------------------------------------- + + +class FunctionDeployment(BaseModel): + """Request body for deploying or updating a function.""" + + service: str + image: str + namespace: str | None = None + env_process: str | None = Field(default=None, alias="envProcess") + env_vars: dict[str, str] | None = Field(default=None, alias="envVars") + constraints: list[str] | None = None + secrets: list[str] | None = None + labels: dict[str, str] | None = None + annotations: dict[str, str] | None = None + limits: FunctionResources | None = None + requests: FunctionResources | None = None + read_only_root_filesystem: bool | None = Field(default=None, alias="readOnlyRootFilesystem") + + model_config = {"populate_by_name": True} + + def to_api_dict(self) -> dict[str, Any]: + """Serialise to the JSON shape expected by the OpenFaaS API.""" + return self.model_dump(by_alias=True, exclude_none=True) + + +# --------------------------------------------------------------------------- +# Function status (response body from GET /system/functions) +# --------------------------------------------------------------------------- + + +class FunctionUsage(BaseModel): + cpu: float | None = Field(default=None) + total_memory_bytes: float | None = Field(default=None, alias="totalMemoryBytes") + + model_config = {"populate_by_name": True} + + +class FunctionStatus(BaseModel): + """Response body for a single function returned by the API.""" + + name: str = Field(alias="name") + image: str = Field(default="") + namespace: str | None = None + env_process: str | None = Field(default=None, alias="envProcess") + env_vars: dict[str, str] | None = Field(default=None, alias="envVars") + constraints: list[str] | None = None + secrets: list[str] | None = None + labels: dict[str, str] | None = None + annotations: dict[str, str] | None = None + limits: FunctionResources | None = None + requests: FunctionResources | None = None + read_only_root_filesystem: bool | None = Field(default=None, alias="readOnlyRootFilesystem") + invocation_count: float = Field(default=0.0, alias="invocationCount") + replicas: int = Field(default=0) + available_replicas: int = Field(default=0, alias="availableReplicas") + created_at: datetime | None = Field(default=None, alias="createdAt") + usage: FunctionUsage | None = None + + model_config = {"populate_by_name": True} + + +# --------------------------------------------------------------------------- +# Namespaces +# --------------------------------------------------------------------------- + + +class FunctionNamespace(BaseModel): + name: str = Field(default="") + labels: dict[str, str] | None = None + annotations: dict[str, str] | None = None + + def to_api_dict(self) -> dict[str, Any]: + return self.model_dump(exclude_none=True) + + +# --------------------------------------------------------------------------- +# Secrets +# --------------------------------------------------------------------------- + + +class Secret(BaseModel): + name: str + namespace: str | None = None + value: str | None = None + + def to_api_dict(self) -> dict[str, Any]: + return self.model_dump(exclude_none=True) + + +# --------------------------------------------------------------------------- +# Logs +# --------------------------------------------------------------------------- + + +class LogMessage(BaseModel): + name: str = Field(default="") + namespace: str | None = None + instance: str | None = None + timestamp: datetime | None = None + text: str = Field(default="") diff --git a/openfaas/token.py b/openfaas/token.py new file mode 100644 index 0000000..6c81f10 --- /dev/null +++ b/openfaas/token.py @@ -0,0 +1,68 @@ +""" +Token types for the OpenFaaS Python SDK. + +Represents an access token returned by the OpenFaaS IAM token exchange +endpoint, along with related error and parsing helpers. +""" + +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from typing import Any + +# Expire tokens 10 seconds early to account for clock skew. +_EXPIRY_DELTA = timedelta(seconds=10) + + +@dataclass +class Token: + """An access token returned by the OpenFaaS gateway or an IdP. + + Attributes: + id_token: The raw JWT / access token string. + expiry: When the token expires. ``None`` means the token never + expires — the token is treated as permanently valid. + scope: List of scopes granted with this token. + """ + + id_token: str + expiry: datetime | None = None + scope: list[str] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType] + + def is_expired(self) -> bool: + """Return ``True`` if the token has expired (or is about to within 10 s).""" + if self.expiry is None: + return False + now = datetime.now(tz=timezone.utc) + return now >= self.expiry - _EXPIRY_DELTA + + +class OAuthError(Exception): + """Raised when an OAuth 2.0 token endpoint returns an error response. + + Attributes: + error: The ``error`` field from the JSON response body. + error_description: The optional ``error_description`` field. + """ + + def __init__(self, error: str, error_description: str = "") -> None: + self.error = error + self.error_description = error_description + if error_description: + super().__init__(f"{error}: {error_description}") + else: + super().__init__(error) + + +def parse_token_response(data: dict[str, Any]) -> Token: + """Parse a successful OAuth JSON token response into a :class:`Token`.""" + id_token: str = data.get("access_token", "") + expires_in: int | None = data.get("expires_in") + scope_str: str = data.get("scope", "") + + expiry: datetime | None = None + if expires_in: + expiry = datetime.now(tz=timezone.utc) + timedelta(seconds=expires_in) + + scope = scope_str.split() if scope_str else [] + + return Token(id_token=id_token, expiry=expiry, scope=scope) diff --git a/openfaas/token_cache.py b/openfaas/token_cache.py new file mode 100644 index 0000000..d145517 --- /dev/null +++ b/openfaas/token_cache.py @@ -0,0 +1,70 @@ +""" +Token cache for the OpenFaaS Python SDK. + +Provides a thread-safe in-memory cache for :class:`~openfaas.token.Token` +instances, used to avoid redundant token exchanges for per-function IAM tokens. +""" + +from __future__ import annotations + +import threading +from abc import ABC, abstractmethod + +from openfaas.token import Token + + +class TokenCache(ABC): + """Abstract base class for token caches.""" + + @abstractmethod + def get(self, key: str) -> Token | None: + """Return the cached token for *key*, or ``None`` if absent or expired.""" + ... + + @abstractmethod + def set(self, key: str, token: Token) -> None: + """Store *token* under *key*.""" + ... + + +class MemoryTokenCache(TokenCache): + """Thread-safe in-memory token cache. + + Expired tokens are evicted eagerly on :meth:`get`. + + Example:: + + cache = MemoryTokenCache() + client = Client( + "https://gateway.example.com", + auth=token_auth, + token_cache=cache, + ) + """ + + def __init__(self) -> None: + self._tokens: dict[str, Token] = {} + self._lock = threading.RLock() + + def get(self, key: str) -> Token | None: + """Return the token for *key*, or ``None`` if missing or expired.""" + with self._lock: + token = self._tokens.get(key) + if token is None: + return None + if token.is_expired(): + del self._tokens[key] + return None + return token + + def set(self, key: str, token: Token) -> None: + """Store *token* under *key*.""" + with self._lock: + self._tokens[key] = token + + def clear_expired(self) -> None: + """Remove all expired tokens from the cache.""" + with self._lock: + expired_keys = [k for k, t in self._tokens.items() if t.is_expired()] + for key in expired_keys: + del self._tokens[key] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c06a5e5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,62 @@ +[project] +name = "openfaas-sdk" +version = "0.1.0" +description = "The official Python SDK for OpenFaaS" +readme = "README.md" +license = "MIT" +authors = [ + { name = "OpenFaaS Ltd", email = "support@openfaas.com" }, +] +requires-python = ">= 3.10" +classifiers = [ + "Typing :: Typed", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Operating System :: OS Independent", + "Topic :: Software Development :: Libraries :: Python Modules", + "License :: OSI Approved :: MIT License", +] +dependencies = [ + "requests>=2.20.0, <3", + "pydantic>=2.0.0, <3", +] + +[project.urls] +Homepage = "https://github.com/openfaas/python-sdk" +Repository = "https://github.com/openfaas/python-sdk" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["openfaas"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.pyright] +typeCheckingMode = "strict" +pythonVersion = "3.10" +include = ["openfaas"] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[tool.ruff.lint] +select = ["I", "B", "F401", "E722", "T201"] + +[tool.ruff.lint.per-file-ignores] +"examples/**" = ["T201"] + +[dependency-groups] +dev = [ + "pyright>=1.1.408", + "pytest>=9.0.3", + "requests-mock>=1.12.1", + "ruff>=0.15.11", +] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..2d9db2b --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,43 @@ +"""Tests for auth implementations.""" + +from __future__ import annotations + +import base64 + +import requests +import requests.auth + +from openfaas.auth import BasicAuth + + +def _apply_auth(auth: requests.auth.AuthBase) -> requests.PreparedRequest: + """Build a PreparedRequest and run auth through it.""" + req = requests.Request("GET", "http://gateway.example.com/system/functions") + prepared = req.prepare() + return auth(prepared) + + +class TestBasicAuth: + def test_sets_authorization_header(self) -> None: + auth = BasicAuth(username="admin", password="secret") + prepared = _apply_auth(auth) + assert "Authorization" in prepared.headers + + def test_header_is_basic_scheme(self) -> None: + auth = BasicAuth(username="admin", password="secret") + prepared = _apply_auth(auth) + assert prepared.headers["Authorization"].startswith("Basic ") + + def test_header_encodes_credentials_correctly(self) -> None: + auth = BasicAuth(username="admin", password="secret") + prepared = _apply_auth(auth) + encoded = prepared.headers["Authorization"].removeprefix("Basic ") + decoded = base64.b64decode(encoded).decode() + assert decoded == "admin:secret" + + def test_repr(self) -> None: + auth = BasicAuth(username="admin", password="secret") + assert repr(auth) == "BasicAuth(username='admin')" + + def test_is_requests_auth(self) -> None: + assert isinstance(BasicAuth("admin", "secret"), requests.auth.AuthBase) diff --git a/tests/test_builder.py b/tests/test_builder.py new file mode 100644 index 0000000..4208e4a --- /dev/null +++ b/tests/test_builder.py @@ -0,0 +1,433 @@ +""" +Tests for the openfaas.builder subpackage. + +Covers: +- BuildConfig serialisation +- BuildResult deserialisation +- make_tar archive structure +- create_build_context directory assembly +- FunctionBuilder.build (non-streaming) +- FunctionBuilder.build_stream (NDJSON streaming) +- HMAC signing header +- Non-2xx error propagation +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import tarfile +from pathlib import Path + +import pytest +import requests +import requests_mock as req_mock + +from openfaas.builder import ( + BUILD_FAILED, + BUILD_IN_PROGRESS, + BUILD_SUCCESS, + BUILDER_CONFIG_FILE_NAME, + BuildConfig, + BuildResult, + FunctionBuilder, + create_build_context, + make_tar, +) + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +_BUILDER_URL = "http://builder.example.com" +_BUILD_URL = f"{_BUILDER_URL}/build" + + +@pytest.fixture() +def tmp(tmp_path: Path) -> Path: + return tmp_path + + +@pytest.fixture() +def simple_context(tmp: Path) -> Path: + """A minimal build context directory with one file.""" + ctx = tmp / "context" + ctx.mkdir() + (ctx / "handler.py").write_text("def handle(req): return req\n") + return ctx + + +@pytest.fixture() +def tar_file(tmp: Path, simple_context: Path) -> Path: + """A tar archive built from simple_context.""" + config = BuildConfig(image="ttl.sh/hello:1h") + path = str(tmp / "req.tar") + make_tar(path, str(simple_context), config) + return Path(path) + + +# --------------------------------------------------------------------------- +# BuildConfig +# --------------------------------------------------------------------------- + + +class TestBuildConfig: + def test_minimal_to_dict(self) -> None: + cfg = BuildConfig(image="ttl.sh/hello:1h") + d = cfg.to_dict() + assert d == {"image": "ttl.sh/hello:1h"} + + def test_build_args_included(self) -> None: + cfg = BuildConfig(image="img", build_args={"PY_VERSION": "3.12"}) + assert cfg.to_dict()["buildArgs"] == {"PY_VERSION": "3.12"} + + def test_empty_build_args_omitted(self) -> None: + cfg = BuildConfig(image="img", build_args={}) + assert "buildArgs" not in cfg.to_dict() + + def test_platforms_included(self) -> None: + cfg = BuildConfig(image="img", platforms=["linux/amd64", "linux/arm64"]) + assert cfg.to_dict()["platforms"] == ["linux/amd64", "linux/arm64"] + + def test_empty_platforms_omitted(self) -> None: + cfg = BuildConfig(image="img") + assert "platforms" not in cfg.to_dict() + + def test_skip_push_included_when_true(self) -> None: + cfg = BuildConfig(image="img", skip_push=True) + assert cfg.to_dict()["skipPush"] is True + + def test_skip_push_omitted_when_false(self) -> None: + cfg = BuildConfig(image="img", skip_push=False) + assert "skipPush" not in cfg.to_dict() + + +# --------------------------------------------------------------------------- +# BuildResult +# --------------------------------------------------------------------------- + + +class TestBuildResult: + def test_from_dict_full(self) -> None: + data = { + "log": ["step 1", "step 2"], + "image": "ttl.sh/hello:1h", + "status": BUILD_SUCCESS, + "error": "", + } + result = BuildResult.from_dict(data) + assert result.log == ["step 1", "step 2"] + assert result.image == "ttl.sh/hello:1h" + assert result.status == BUILD_SUCCESS + assert result.error == "" + + def test_from_dict_missing_optional_fields(self) -> None: + result = BuildResult.from_dict({"status": BUILD_FAILED}) + assert result.log == [] + assert result.image == "" + assert result.error == "" + + def test_from_dict_error_present(self) -> None: + result = BuildResult.from_dict({"status": BUILD_FAILED, "error": "push failed"}) + assert result.error == "push failed" + + def test_status_constants(self) -> None: + assert BUILD_IN_PROGRESS == "in_progress" + assert BUILD_SUCCESS == "success" + assert BUILD_FAILED == "failed" + + +# --------------------------------------------------------------------------- +# make_tar +# --------------------------------------------------------------------------- + + +class TestMakeTar: + def test_config_file_present(self, tar_file: Path) -> None: + with tarfile.open(str(tar_file)) as tar: + names = tar.getnames() + assert BUILDER_CONFIG_FILE_NAME in names + + def test_config_file_content(self, tar_file: Path, simple_context: Path) -> None: + with tarfile.open(str(tar_file)) as tar: + member = tar.extractfile(BUILDER_CONFIG_FILE_NAME) + assert member is not None + data = json.loads(member.read()) + assert data["image"] == "ttl.sh/hello:1h" + + def test_context_dir_present(self, tar_file: Path) -> None: + with tarfile.open(str(tar_file)) as tar: + names = tar.getnames() + # context dir entries should be rooted under "context/" + assert any(n.startswith("context") for n in names) + + def test_handler_file_in_context(self, tar_file: Path) -> None: + with tarfile.open(str(tar_file)) as tar: + names = tar.getnames() + assert "context/handler.py" in names + + def test_all_build_args_serialised(self, tmp: Path, simple_context: Path) -> None: + config = BuildConfig( + image="img", + build_args={"FOO": "bar"}, + platforms=["linux/amd64"], + skip_push=True, + ) + path = str(tmp / "full.tar") + make_tar(path, str(simple_context), config) + with tarfile.open(path) as tar: + member = tar.extractfile(BUILDER_CONFIG_FILE_NAME) + assert member is not None + data = json.loads(member.read()) + assert data["buildArgs"] == {"FOO": "bar"} + assert data["platforms"] == ["linux/amd64"] + assert data["skipPush"] is True + + +# --------------------------------------------------------------------------- +# create_build_context +# --------------------------------------------------------------------------- + + +class TestCreateBuildContext: + def _make_template(self, root: Path, lang: str) -> Path: + tmpl = root / "template" / lang + tmpl.mkdir(parents=True) + (tmpl / "Dockerfile").write_text("FROM python:3.12\n") + handler_dir = tmpl / "function" + handler_dir.mkdir() + (handler_dir / "requirements.txt").write_text("") + return tmpl + + def _make_handler(self, root: Path) -> Path: + handler = root / "my-handler" + handler.mkdir() + (handler / "handler.py").write_text("def handle(req): return req\n") + return handler + + def test_context_created(self, tmp: Path) -> None: + self._make_template(tmp, "python3") + handler = self._make_handler(tmp) + ctx = create_build_context( + "my-fn", + str(handler), + "python3", + build_dir=str(tmp / "build"), + template_dir=str(tmp / "template"), + ) + assert Path(ctx).is_dir() + + def test_template_files_copied(self, tmp: Path) -> None: + self._make_template(tmp, "python3") + handler = self._make_handler(tmp) + ctx = create_build_context( + "my-fn", + str(handler), + "python3", + build_dir=str(tmp / "build"), + template_dir=str(tmp / "template"), + ) + assert (Path(ctx) / "Dockerfile").exists() + + def test_handler_overlaid(self, tmp: Path) -> None: + self._make_template(tmp, "python3") + handler = self._make_handler(tmp) + ctx = create_build_context( + "my-fn", + str(handler), + "python3", + build_dir=str(tmp / "build"), + template_dir=str(tmp / "template"), + ) + assert (Path(ctx) / "function" / "handler.py").exists() + + def test_handler_build_subdir_skipped(self, tmp: Path) -> None: + self._make_template(tmp, "python3") + handler = self._make_handler(tmp) + (handler / "build").mkdir() + (handler / "build" / "artifact.bin").write_bytes(b"\x00") + ctx = create_build_context( + "my-fn", + str(handler), + "python3", + build_dir=str(tmp / "build"), + template_dir=str(tmp / "template"), + ) + assert not (Path(ctx) / "function" / "build").exists() + + def test_handler_template_subdir_skipped(self, tmp: Path) -> None: + self._make_template(tmp, "python3") + handler = self._make_handler(tmp) + (handler / "template").mkdir() + (handler / "template" / "tmpl.yaml").write_text("lang: python3\n") + ctx = create_build_context( + "my-fn", + str(handler), + "python3", + build_dir=str(tmp / "build"), + template_dir=str(tmp / "template"), + ) + assert not (Path(ctx) / "function" / "template").exists() + + def test_missing_template_raises(self, tmp: Path) -> None: + handler = self._make_handler(tmp) + with pytest.raises(FileNotFoundError, match="Template directory not found"): + create_build_context( + "my-fn", + str(handler), + "nonexistent", + build_dir=str(tmp / "build"), + template_dir=str(tmp / "template"), + ) + + def test_dockerfile_skips_template_copy(self, tmp: Path) -> None: + handler = self._make_handler(tmp) + (handler / "Dockerfile").write_text("FROM scratch\n") + ctx = create_build_context( + "my-fn", + str(handler), + "dockerfile", + build_dir=str(tmp / "build"), + template_dir=str(tmp / "template"), + ) + # Only the handler overlay should be present — no template Dockerfile + assert (Path(ctx) / "function" / "Dockerfile").exists() + + def test_traversal_in_function_name_raises(self, tmp: Path) -> None: + handler = self._make_handler(tmp) + with pytest.raises(ValueError, match="function_name must not contain path separators"): + create_build_context( + "../../../etc", + str(handler), + "dockerfile", + build_dir=str(tmp / "build"), + template_dir=str(tmp / "template"), + ) + + def test_traversal_in_language_raises(self, tmp: Path) -> None: + handler = self._make_handler(tmp) + with pytest.raises(ValueError, match="language must not contain path separators"): + create_build_context( + "my-fn", + str(handler), + "../../../etc", + build_dir=str(tmp / "build"), + template_dir=str(tmp / "template"), + ) + + def test_traversal_in_handler_overlay_raises(self, tmp: Path) -> None: + self._make_template(tmp, "python3") + handler = self._make_handler(tmp) + with pytest.raises(ValueError, match="handler_overlay must not contain path separators"): + create_build_context( + "my-fn", + str(handler), + "python3", + build_dir=str(tmp / "build"), + template_dir=str(tmp / "template"), + handler_overlay="../../../etc", + ) + + +# --------------------------------------------------------------------------- +# FunctionBuilder +# --------------------------------------------------------------------------- + + +class TestFunctionBuilder: + _SUCCESS_BODY = json.dumps( + { + "log": ["Building...", "Done."], + "image": "ttl.sh/hello:1h", + "status": BUILD_SUCCESS, + } + ) + + def test_build_success(self, tar_file: Path) -> None: + with req_mock.Mocker() as m: + m.post(_BUILD_URL, text=self._SUCCESS_BODY, status_code=200) + builder = FunctionBuilder(_BUILDER_URL) + result = builder.build(str(tar_file)) + assert result.status == BUILD_SUCCESS + assert result.image == "ttl.sh/hello:1h" + assert result.log == ["Building...", "Done."] + + def test_build_202_accepted(self, tar_file: Path) -> None: + with req_mock.Mocker() as m: + m.post(_BUILD_URL, text=self._SUCCESS_BODY, status_code=202) + builder = FunctionBuilder(_BUILDER_URL) + result = builder.build(str(tar_file)) + assert result.status == BUILD_SUCCESS + + def test_build_non_2xx_raises(self, tar_file: Path) -> None: + with req_mock.Mocker() as m: + m.post(_BUILD_URL, text="Unauthorized", status_code=401) + builder = FunctionBuilder(_BUILDER_URL) + with pytest.raises(requests.HTTPError): + builder.build(str(tar_file)) + + def test_build_sets_content_type(self, tar_file: Path) -> None: + with req_mock.Mocker() as m: + m.post(_BUILD_URL, text=self._SUCCESS_BODY) + builder = FunctionBuilder(_BUILDER_URL) + builder.build(str(tar_file)) + assert m.last_request.headers["Content-Type"] == "application/octet-stream" + + def test_build_no_hmac_header_without_secret(self, tar_file: Path) -> None: + with req_mock.Mocker() as m: + m.post(_BUILD_URL, text=self._SUCCESS_BODY) + builder = FunctionBuilder(_BUILDER_URL) + builder.build(str(tar_file)) + assert "X-Build-Signature" not in m.last_request.headers + + def test_build_hmac_header_present_with_secret(self, tar_file: Path) -> None: + secret = "my-hmac-secret" + with req_mock.Mocker() as m: + m.post(_BUILD_URL, text=self._SUCCESS_BODY) + builder = FunctionBuilder(_BUILDER_URL, hmac_secret=secret) + builder.build(str(tar_file)) + sig = m.last_request.headers.get("X-Build-Signature", "") + assert sig.startswith("sha256=") + + def test_build_hmac_header_correct_digest(self, tar_file: Path) -> None: + secret = "my-hmac-secret" + body = tar_file.read_bytes() + expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() + with req_mock.Mocker() as m: + m.post(_BUILD_URL, text=self._SUCCESS_BODY) + builder = FunctionBuilder(_BUILDER_URL, hmac_secret=secret) + builder.build(str(tar_file)) + sig = m.last_request.headers["X-Build-Signature"] + assert sig == f"sha256={expected}" + + def test_build_stream_yields_results(self, tar_file: Path) -> None: + lines = [ + json.dumps({"log": ["step 1"], "image": "", "status": BUILD_IN_PROGRESS}), + json.dumps({"log": ["step 2"], "image": "", "status": BUILD_IN_PROGRESS}), + json.dumps({"log": [], "image": "ttl.sh/hello:1h", "status": BUILD_SUCCESS}), + ] + ndjson_body = "\n".join(lines) + with req_mock.Mocker() as m: + m.post(_BUILD_URL, text=ndjson_body) + builder = FunctionBuilder(_BUILDER_URL) + results = list(builder.build_stream(str(tar_file))) + assert len(results) == 3 + assert results[0].status == BUILD_IN_PROGRESS + assert results[0].log == ["step 1"] + assert results[2].status == BUILD_SUCCESS + assert results[2].image == "ttl.sh/hello:1h" + + def test_build_stream_sets_accept_header(self, tar_file: Path) -> None: + with req_mock.Mocker() as m: + m.post(_BUILD_URL, text="") + builder = FunctionBuilder(_BUILDER_URL) + list(builder.build_stream(str(tar_file))) + assert m.last_request.headers["Accept"] == "application/x-ndjson" + + def test_build_stream_non_2xx_raises(self, tar_file: Path) -> None: + with req_mock.Mocker() as m: + m.post(_BUILD_URL, text="Forbidden", status_code=403) + builder = FunctionBuilder(_BUILDER_URL) + with pytest.raises(requests.HTTPError): + list(builder.build_stream(str(tar_file))) diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..19c8318 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,374 @@ +"""Tests for the sync Client using requests-mock.""" + +from __future__ import annotations + +import re +from collections.abc import Iterator +from unittest.mock import patch + +import pytest +import requests +import requests_mock as req_mock + +from openfaas import BasicAuth, Client +from openfaas.exceptions import NotFoundError +from openfaas.models import FunctionDeployment, FunctionNamespace, Secret + +# --------------------------------------------------------------------------- +# Shared test data +# --------------------------------------------------------------------------- + +GATEWAY = "http://gateway.example.com" + +FUNCTIONS_LIST = [ + { + "name": "env", + "image": "ghcr.io/openfaas/env:latest", + "namespace": "openfaas-fn", + "replicas": 1, + "availableReplicas": 1, + "invocationCount": 0, + }, +] + +FUNCTION_DETAIL = { + "name": "env", + "image": "ghcr.io/openfaas/env:latest", + "namespace": "openfaas-fn", + "replicas": 1, + "availableReplicas": 1, + "invocationCount": 5, +} + +NAMESPACES_LIST = ["openfaas-fn", "staging"] + +NAMESPACE_DETAIL = {"name": "openfaas-fn", "labels": {"openfaas": "1"}} + +SECRETS_LIST = [{"name": "my-secret", "namespace": "openfaas-fn"}] + +SYSTEM_INFO = { + "arch": "amd64", + "provider": {"provider": "faas", "orchestration": "kubernetes"}, + "version": {"release": "0.27.0"}, +} + +LOG_LINES = [ + '{"name":"env","namespace":"openfaas-fn","instance":"env-xxx","text":"starting"}', + '{"name":"env","namespace":"openfaas-fn","instance":"env-xxx","text":"ready"}', +] + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def mock_gateway() -> Iterator[req_mock.Mocker]: + """Activate requests-mock and register all gateway endpoints.""" + with req_mock.Mocker() as m: + m.get(f"{GATEWAY}/system/info", json=SYSTEM_INFO) + m.get(f"{GATEWAY}/system/namespaces", json=NAMESPACES_LIST) + m.get(req_mock.ANY, json=NAMESPACE_DETAIL) # /system/namespace/ + m.post(f"{GATEWAY}/system/namespace/", status_code=201) + m.put(req_mock.ANY, status_code=200) + m.delete(req_mock.ANY, status_code=200) + m.get(f"{GATEWAY}/system/functions", json=FUNCTIONS_LIST) + m.post(f"{GATEWAY}/system/functions", status_code=202) + m.get(f"{GATEWAY}/system/secrets", json=SECRETS_LIST) + m.post(f"{GATEWAY}/system/secrets", status_code=201) + m.get(f"{GATEWAY}/system/logs", text="\n".join(LOG_LINES)) + yield m + + +@pytest.fixture() +def client(mock_gateway: req_mock.Mocker) -> Iterator[Client]: + with Client(GATEWAY, auth=BasicAuth("admin", "secret")) as c: + yield c + + +# --------------------------------------------------------------------------- +# Per-endpoint mock helpers used by tests that need specific routing +# --------------------------------------------------------------------------- + + +def _echo_handler(request: requests.PreparedRequest, context: object) -> dict: # type: ignore[type-arg] + """Echo back path, method, body and headers — used for invoke assertions.""" + return { + "path": request.path_url.split("?")[0], + "method": request.method, + "body": request.body.decode() if isinstance(request.body, bytes) else (request.body or ""), + "headers": dict(request.headers), + "callback_url": request.headers.get("X-Callback-Url", ""), + } + + +def _make_client(m: req_mock.Mocker) -> Client: + """Register the standard routes on *m* and return a Client.""" + m.get(f"{GATEWAY}/system/info", json=SYSTEM_INFO) + m.get(f"{GATEWAY}/system/namespaces", json=NAMESPACES_LIST) + m.get(f"{GATEWAY}/system/namespace/openfaas-fn", json=NAMESPACE_DETAIL) + m.post(f"{GATEWAY}/system/namespace/", status_code=201) + m.put(f"{GATEWAY}/system/namespace/staging", status_code=200) + m.delete(f"{GATEWAY}/system/namespace/staging", status_code=200) + m.get(f"{GATEWAY}/system/functions", json=FUNCTIONS_LIST) + m.get(f"{GATEWAY}/system/function/env", json=FUNCTION_DETAIL) + m.get(f"{GATEWAY}/system/function/missing", status_code=404) + m.post(f"{GATEWAY}/system/functions", status_code=202) + m.put(f"{GATEWAY}/system/functions", status_code=200) + m.delete(f"{GATEWAY}/system/functions", status_code=200) + m.post(f"{GATEWAY}/system/scale-function/env", status_code=202) + m.get(f"{GATEWAY}/system/secrets", json=SECRETS_LIST) + m.post(f"{GATEWAY}/system/secrets", status_code=201) + m.put(f"{GATEWAY}/system/secrets", status_code=200) + m.delete(f"{GATEWAY}/system/secrets", status_code=200) + m.get(f"{GATEWAY}/system/logs", text="\n".join(LOG_LINES)) + m.get(f"{GATEWAY}/auth-required", status_code=401) + m.get(f"{GATEWAY}/forbidden", status_code=403) + # Function invocation echo — matches any method on /function/... and /async-function/... + m.register_uri(req_mock.ANY, re.compile(rf"{re.escape(GATEWAY)}/(async-function|function)/.*"), json=_echo_handler) + return Client(GATEWAY, auth=BasicAuth("admin", "secret")) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestClientSync: + def test_get_info(self) -> None: + with req_mock.Mocker() as m: + c = _make_client(m) + info = c.get_info() + assert info.arch == "amd64" + assert info.provider.orchestration == "kubernetes" + + def test_get_namespaces(self) -> None: + with req_mock.Mocker() as m: + c = _make_client(m) + ns = c.get_namespaces() + assert "openfaas-fn" in ns + + def test_get_namespace(self) -> None: + with req_mock.Mocker() as m: + c = _make_client(m) + ns = c.get_namespace("openfaas-fn") + assert ns.name == "openfaas-fn" + + def test_create_namespace(self) -> None: + with req_mock.Mocker() as m: + c = _make_client(m) + status = c.create_namespace(FunctionNamespace(name="staging")) + assert status == 201 + + def test_update_namespace(self) -> None: + with req_mock.Mocker() as m: + c = _make_client(m) + status = c.update_namespace(FunctionNamespace(name="staging")) + assert status == 200 + + def test_delete_namespace(self) -> None: + with req_mock.Mocker() as m: + c = _make_client(m) + c.delete_namespace("staging") # should not raise + + def test_get_functions(self) -> None: + with req_mock.Mocker() as m: + c = _make_client(m) + fns = c.get_functions("openfaas-fn") + assert len(fns) == 1 + assert fns[0].name == "env" + + def test_get_function(self) -> None: + with req_mock.Mocker() as m: + c = _make_client(m) + fn = c.get_function("env", "openfaas-fn") + assert fn.name == "env" + assert fn.invocation_count == 5 + + def test_get_function_not_found(self) -> None: + with req_mock.Mocker() as m: + c = _make_client(m) + with pytest.raises(NotFoundError): + c.get_function("missing", "openfaas-fn") + + def test_deploy(self) -> None: + with req_mock.Mocker() as m: + c = _make_client(m) + spec = FunctionDeployment(service="env", image="ghcr.io/openfaas/env:latest", namespace="openfaas-fn") + status = c.deploy(spec) + assert status == 202 + + def test_update(self) -> None: + with req_mock.Mocker() as m: + c = _make_client(m) + spec = FunctionDeployment(service="env", image="ghcr.io/openfaas/env:latest") + status = c.update(spec) + assert status == 200 + + def test_delete_function(self) -> None: + with req_mock.Mocker() as m: + c = _make_client(m) + c.delete_function("env", "openfaas-fn") # should not raise + + def test_scale_function(self) -> None: + with req_mock.Mocker() as m: + c = _make_client(m) + c.scale_function("env", 3, "openfaas-fn") # should not raise + + def test_get_secrets(self) -> None: + with req_mock.Mocker() as m: + c = _make_client(m) + secrets = c.get_secrets("openfaas-fn") + assert len(secrets) == 1 + assert secrets[0].name == "my-secret" + + def test_create_secret(self) -> None: + with req_mock.Mocker() as m: + c = _make_client(m) + status = c.create_secret(Secret(name="new-secret", namespace="openfaas-fn", value="val")) + assert status == 201 + + def test_update_secret(self) -> None: + with req_mock.Mocker() as m: + c = _make_client(m) + status = c.update_secret(Secret(name="my-secret", value="new-val")) + assert status == 200 + + def test_delete_secret(self) -> None: + with req_mock.Mocker() as m: + c = _make_client(m) + c.delete_secret("my-secret", "openfaas-fn") # should not raise + + def test_context_manager(self) -> None: + with req_mock.Mocker() as m: + c = _make_client(m) + with c: + ns = c.get_namespaces() + assert isinstance(ns, list) + + def test_get_logs(self) -> None: + with req_mock.Mocker() as m: + c = _make_client(m) + msgs = list(c.get_logs("env", "openfaas-fn")) + assert len(msgs) == 2 + assert msgs[0].text == "starting" + assert msgs[1].text == "ready" + + +# --------------------------------------------------------------------------- +# --------------------------------------------------------------------------- +# invoke_function / invoke_function_async +# --------------------------------------------------------------------------- + + +class TestClientInvokeSync: + def test_invoke_basic_post(self) -> None: + with req_mock.Mocker() as m: + c = _make_client(m) + resp = c.invoke_function("env", method="POST", payload=b"hello") + assert resp.status_code == 200 + data = resp.json() + assert data["path"] == "/function/env.openfaas-fn" + assert data["method"] == "POST" + assert data["body"] == "hello" + + def test_invoke_str_payload(self) -> None: + with req_mock.Mocker() as m: + c = _make_client(m) + resp = c.invoke_function("env", method="POST", payload="world") + assert resp.json()["body"] == "world" + + def test_invoke_no_payload(self) -> None: + with req_mock.Mocker() as m: + c = _make_client(m) + resp = c.invoke_function("env", method="POST") + assert resp.status_code == 200 + assert resp.json()["body"] == "" + + def test_invoke_custom_method(self) -> None: + with req_mock.Mocker() as m: + c = _make_client(m) + resp = c.invoke_function("env", method="GET") + assert resp.json()["method"] == "GET" + + def test_invoke_custom_namespace(self) -> None: + with req_mock.Mocker() as m: + c = _make_client(m) + resp = c.invoke_function("env", "staging", method="POST") + assert resp.json()["path"] == "/function/env.staging" + + def test_invoke_custom_headers(self) -> None: + with req_mock.Mocker() as m: + c = _make_client(m) + resp = c.invoke_function("env", method="POST", headers={"X-Custom": "value"}) + assert resp.json()["headers"]["X-Custom"] == "value" + + def test_invoke_query_params(self) -> None: + with req_mock.Mocker() as m: + c = _make_client(m) + resp = c.invoke_function("env", method="GET", query_params={"foo": "bar"}) + assert resp.status_code == 200 + + def test_non_2xx_not_raised(self) -> None: + with req_mock.Mocker() as m: + c = _make_client(m) + # Register a 500 for a specific function path — should be returned, not raised + m.post(f"{GATEWAY}/function/broken.openfaas-fn", status_code=500, text="internal error") + resp = c.invoke_function("broken", method="POST") + assert resp.status_code == 500 + + def test_invoke_with_function_auth(self) -> None: + with req_mock.Mocker() as m: + c = _make_client(m) + with patch.object(c, "get_function_token", return_value="fn-tok") as mock_gft: + resp = c.invoke_function("env", method="POST", use_function_auth=True) + mock_gft.assert_called_once_with("env", "openfaas-fn") + assert resp.json()["headers"]["Authorization"] == "Bearer fn-tok" + + def test_invoke_with_function_auth_custom_namespace(self) -> None: + with req_mock.Mocker() as m: + c = _make_client(m) + with patch.object(c, "get_function_token", return_value="scoped-tok") as mock_gft: + c.invoke_function("env", "staging", method="POST", use_function_auth=True) + mock_gft.assert_called_once_with("env", "staging") + + +class TestClientInvokeAsync: + def test_invoke_async_route(self) -> None: + with req_mock.Mocker() as m: + c = _make_client(m) + resp = c.invoke_function_async("env") + assert resp.json()["path"] == "/async-function/env.openfaas-fn" + + def test_invoke_async_uses_post(self) -> None: + with req_mock.Mocker() as m: + c = _make_client(m) + resp = c.invoke_function_async("env") + assert resp.json()["method"] == "POST" + + def test_invoke_async_custom_namespace(self) -> None: + with req_mock.Mocker() as m: + c = _make_client(m) + resp = c.invoke_function_async("env", "staging") + assert resp.json()["path"] == "/async-function/env.staging" + + def test_invoke_async_with_payload(self) -> None: + with req_mock.Mocker() as m: + c = _make_client(m) + resp = c.invoke_function_async("env", payload=b"data") + assert resp.json()["body"] == "data" + + def test_invoke_async_with_callback_url(self) -> None: + with req_mock.Mocker() as m: + c = _make_client(m) + resp = c.invoke_function_async("env", callback_url="https://example.com/cb") + assert resp.json()["callback_url"] == "https://example.com/cb" + + def test_invoke_async_with_function_auth(self) -> None: + with req_mock.Mocker() as m: + c = _make_client(m) + with patch.object(c, "get_function_token", return_value="fn-tok") as mock_gft: + resp = c.invoke_function_async("env", use_function_auth=True) + mock_gft.assert_called_once_with("env", "openfaas-fn") + assert resp.json()["headers"]["Authorization"] == "Bearer fn-tok" diff --git a/tests/test_iam.py b/tests/test_iam.py new file mode 100644 index 0000000..a42c270 --- /dev/null +++ b/tests/test_iam.py @@ -0,0 +1,429 @@ +"""Tests for OpenFaaS IAM authentication components. + +Covers: +- Token / OAuthError / parse_token_response +- MemoryTokenCache +- exchange_id_token +- ServiceAccountTokenSource +- ClientCredentialsTokenSource +- TokenAuth (sync auth_flow, token) +""" + +from __future__ import annotations + +import os +import threading +from datetime import datetime, timedelta, timezone +from pathlib import Path +from unittest.mock import patch + +import pytest +import requests +import requests_mock as req_mock + +from openfaas.auth import ( + ClientCredentialsTokenSource, + ServiceAccountTokenSource, + TokenAuth, + TokenSource, +) +from openfaas.exchange import exchange_id_token +from openfaas.token import OAuthError, Token, parse_token_response +from openfaas.token_cache import MemoryTokenCache + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_FUTURE = datetime.now(tz=timezone.utc) + timedelta(hours=1) +_PAST = datetime.now(tz=timezone.utc) - timedelta(seconds=1) +_ALMOST_EXPIRED = datetime.now(tz=timezone.utc) + timedelta(seconds=5) + + +def _token(value: str = "tok", *, expiry: datetime | None = _FUTURE) -> Token: + return Token(id_token=value, expiry=expiry) + + +def _apply_auth(auth: requests.auth.AuthBase) -> requests.PreparedRequest: + """Run auth against a PreparedRequest and return it.""" + req = requests.Request("GET", "http://gateway.example.com/system/functions") + prepared = req.prepare() + return auth(prepared) + + +# --------------------------------------------------------------------------- +# Token +# --------------------------------------------------------------------------- + + +class TestToken: + def test_not_expired_when_no_expiry(self) -> None: + assert Token(id_token="tok").is_expired() is False + + def test_not_expired_when_far_future(self) -> None: + assert _token().is_expired() is False + + def test_expired_when_in_past(self) -> None: + assert Token(id_token="tok", expiry=_PAST).is_expired() is True + + def test_expired_within_10s_delta(self) -> None: + assert Token(id_token="tok", expiry=_ALMOST_EXPIRED).is_expired() is True + + def test_scope_defaults_to_empty(self) -> None: + assert _token().scope == [] + + +class TestOAuthError: + def test_message_without_description(self) -> None: + err = OAuthError("invalid_grant") + assert str(err) == "invalid_grant" + assert err.error == "invalid_grant" + assert err.error_description == "" + + def test_message_with_description(self) -> None: + err = OAuthError("invalid_grant", "token has expired") + assert "invalid_grant" in str(err) + assert "token has expired" in str(err) + + +class TestParseTokenResponse: + def test_basic_response(self) -> None: + data = {"access_token": "mytoken", "expires_in": 3600} + tok = parse_token_response(data) + assert tok.id_token == "mytoken" + assert tok.expiry is not None + assert tok.expiry > datetime.now(tz=timezone.utc) + + def test_scope_parsed(self) -> None: + data = {"access_token": "t", "expires_in": 3600, "scope": "openid function"} + tok = parse_token_response(data) + assert tok.scope == ["openid", "function"] + + def test_no_expires_in_gives_none_expiry(self) -> None: + tok = parse_token_response({"access_token": "t"}) + assert tok.expiry is None + + def test_zero_expires_in_gives_none_expiry(self) -> None: + tok = parse_token_response({"access_token": "t", "expires_in": 0}) + assert tok.expiry is None + + +# --------------------------------------------------------------------------- +# MemoryTokenCache +# --------------------------------------------------------------------------- + + +class TestMemoryTokenCache: + def test_get_returns_none_for_missing_key(self) -> None: + cache = MemoryTokenCache() + assert cache.get("ns:fn") is None + + def test_set_then_get_returns_token(self) -> None: + cache = MemoryTokenCache() + tok = _token("abc") + cache.set("ns:fn", tok) + assert cache.get("ns:fn") is tok + + def test_get_evicts_expired_token(self) -> None: + cache = MemoryTokenCache() + cache.set("ns:fn", Token(id_token="x", expiry=_PAST)) + assert cache.get("ns:fn") is None + + def test_get_evicts_almost_expired_token(self) -> None: + cache = MemoryTokenCache() + cache.set("ns:fn", Token(id_token="x", expiry=_ALMOST_EXPIRED)) + assert cache.get("ns:fn") is None + + def test_set_overwrites_existing(self) -> None: + cache = MemoryTokenCache() + cache.set("k", _token("first")) + cache.set("k", _token("second")) + assert cache.get("k").id_token == "second" # type: ignore[union-attr] + + def test_clear_expired_removes_only_expired(self) -> None: + cache = MemoryTokenCache() + cache.set("alive", _token("a")) + cache.set("dead", Token(id_token="d", expiry=_PAST)) + cache.clear_expired() + assert cache.get("alive") is not None + assert cache.get("dead") is None + + def test_thread_safety(self) -> None: + cache = MemoryTokenCache() + errors: list[Exception] = [] + + def worker(n: int) -> None: + try: + for i in range(50): + key = f"fn-{n}-{i}" + cache.set(key, _token(key)) + cache.get(key) + except Exception as exc: + errors.append(exc) + + threads = [threading.Thread(target=worker, args=(n,)) for n in range(8)] + for t in threads: + t.start() + for t in threads: + t.join() + assert errors == [] + + +# --------------------------------------------------------------------------- +# exchange_id_token +# --------------------------------------------------------------------------- + +_TOKEN_URL = "https://gateway.example.com/oauth/token" +_ID_TOKEN = "eyJhbGciOiJSUzI1NiJ9.fake" + + +class TestExchangeIdToken: + def test_returns_token_on_success(self) -> None: + with req_mock.Mocker() as m: + m.post(_TOKEN_URL, json={"access_token": "gw-token", "expires_in": 3600}) + tok = exchange_id_token(_TOKEN_URL, _ID_TOKEN) + assert tok.id_token == "gw-token" + assert tok.expiry is not None + + def test_sends_subject_token(self) -> None: + with req_mock.Mocker() as m: + m.post(_TOKEN_URL, json={"access_token": "t", "expires_in": 3600}) + exchange_id_token(_TOKEN_URL, "my-id-token") + assert "my-id-token" in m.last_request.text # type: ignore[union-attr] + + def test_raises_oauth_error_on_400(self) -> None: + with req_mock.Mocker() as m: + m.post(_TOKEN_URL, status_code=400, json={"error": "invalid_grant", "error_description": "token expired"}) + with pytest.raises(OAuthError) as exc_info: + exchange_id_token(_TOKEN_URL, _ID_TOKEN) + assert exc_info.value.error == "invalid_grant" + + def test_raises_http_error_on_500(self) -> None: + with req_mock.Mocker() as m: + m.post(_TOKEN_URL, status_code=500) + with pytest.raises(requests.HTTPError): + exchange_id_token(_TOKEN_URL, _ID_TOKEN) + + def test_audience_included_when_provided(self) -> None: + with req_mock.Mocker() as m: + m.post(_TOKEN_URL, json={"access_token": "t", "expires_in": 3600}) + exchange_id_token(_TOKEN_URL, _ID_TOKEN, audience=["openfaas-fn:my-func"]) + assert "audience" in m.last_request.text # type: ignore[union-attr] + + def test_scope_included_when_provided(self) -> None: + with req_mock.Mocker() as m: + m.post(_TOKEN_URL, json={"access_token": "t", "expires_in": 3600}) + exchange_id_token(_TOKEN_URL, _ID_TOKEN, scope=["function"]) + assert "scope=function" in m.last_request.text # type: ignore[union-attr] + + +# --------------------------------------------------------------------------- +# ServiceAccountTokenSource +# --------------------------------------------------------------------------- + + +class TestServiceAccountTokenSource: + def test_reads_token_from_file(self, tmp_path: Path) -> None: + (tmp_path / "openfaas-token").write_text("my-sa-token\n") + src = ServiceAccountTokenSource() + with patch.dict(os.environ, {"token_mount_path": str(tmp_path)}): + assert src.token() == "my-sa-token" + + def test_strips_whitespace(self, tmp_path: Path) -> None: + (tmp_path / "openfaas-token").write_text(" tok \n") + src = ServiceAccountTokenSource() + with patch.dict(os.environ, {"token_mount_path": str(tmp_path)}): + assert src.token() == "tok" + + def test_raises_runtime_error_if_file_missing(self, tmp_path: Path) -> None: + src = ServiceAccountTokenSource() + with patch.dict(os.environ, {"token_mount_path": str(tmp_path)}): + with pytest.raises(RuntimeError, match="Unable to load service account token"): + src.token() + + def test_raises_value_error_if_path_empty(self) -> None: + src = ServiceAccountTokenSource() + with patch.dict(os.environ, {"token_mount_path": ""}): + with pytest.raises(ValueError, match="Invalid token_mount_path"): + src.token() + + def test_re_reads_on_every_call(self, tmp_path: Path) -> None: + token_file = tmp_path / "openfaas-token" + token_file.write_text("first") + src = ServiceAccountTokenSource() + with patch.dict(os.environ, {"token_mount_path": str(tmp_path)}): + assert src.token() == "first" + token_file.write_text("second") + assert src.token() == "second" + + def test_satisfies_token_source_protocol(self) -> None: + assert isinstance(ServiceAccountTokenSource(), TokenSource) + + def test_repr(self) -> None: + src = ServiceAccountTokenSource() + assert "ServiceAccountTokenSource" in repr(src) + assert "openfaas-token" in repr(src) + + +# --------------------------------------------------------------------------- +# ClientCredentialsTokenSource +# --------------------------------------------------------------------------- + +_IDP_TOKEN_URL = "https://idp.example.com/token" + + +class TestClientCredentialsTokenSource: + def _make_source(self) -> ClientCredentialsTokenSource: + return ClientCredentialsTokenSource( + client_id="app", + client_secret="secret", + token_url=_IDP_TOKEN_URL, + scope="openid", + ) + + def test_sync_returns_token(self) -> None: + with req_mock.Mocker() as m: + m.post(_IDP_TOKEN_URL, json={"access_token": "cc-token", "expires_in": 3600}) + src = self._make_source() + assert src.token() == "cc-token" + + def test_sync_caches_valid_token(self) -> None: + call_count = 0 + + def handler(request: requests.PreparedRequest, context: object) -> dict: # type: ignore[type-arg] + nonlocal call_count + call_count += 1 + return {"access_token": "t", "expires_in": 3600} + + with req_mock.Mocker() as m: + m.post(_IDP_TOKEN_URL, json=handler) + src = self._make_source() + src.token() + src.token() + assert call_count == 1 + + def test_satisfies_token_source_protocol(self) -> None: + src = ClientCredentialsTokenSource(client_id="a", client_secret="b", token_url=_IDP_TOKEN_URL) + assert isinstance(src, TokenSource) + + def test_repr_does_not_leak_secret(self) -> None: + src = ClientCredentialsTokenSource(client_id="app", client_secret="topsecret", token_url=_IDP_TOKEN_URL) + r = repr(src) + assert "ClientCredentialsTokenSource" in r + assert "app" in r + assert "topsecret" not in r + + def test_custom_session_is_used(self) -> None: + with req_mock.Mocker() as m: + m.post(_IDP_TOKEN_URL, json={"access_token": "cc-token", "expires_in": 3600}) + session = requests.Session() + src = ClientCredentialsTokenSource( + client_id="app", + client_secret="secret", + token_url=_IDP_TOKEN_URL, + http_client=session, + ) + assert src.token() == "cc-token" + # session should not be closed by the source + assert session.get # still usable + + def test_custom_session_is_not_closed(self) -> None: + with req_mock.Mocker() as m: + m.post(_IDP_TOKEN_URL, json={"access_token": "cc-token", "expires_in": 3600}) + session = requests.Session() + src = ClientCredentialsTokenSource( + client_id="app", + client_secret="secret", + token_url=_IDP_TOKEN_URL, + http_client=session, + ) + src.token() + # calling token() again should still work (session not closed) + m.post(_IDP_TOKEN_URL, json={"access_token": "cc-token2", "expires_in": 1}) + src._token = None + assert src.token() == "cc-token2" + + +# --------------------------------------------------------------------------- +# TokenAuth +# --------------------------------------------------------------------------- + + +class _FakeTokenSource: + """Minimal sync-only TokenSource.""" + + def __init__(self, value: str = "upstream-token") -> None: + self._value = value + self.call_count = 0 + + def token(self) -> str: + self.call_count += 1 + return self._value + + +def _make_token_auth( + upstream: str = "upstream-token", + gw_token: str = "gw-token", + expires_in: int = 3600, +) -> tuple[TokenAuth, _FakeTokenSource]: + """Build a TokenAuth backed by a _FakeTokenSource.""" + source = _FakeTokenSource(upstream) + auth = TokenAuth(token_url=_TOKEN_URL, token_source=source) + return auth, source + + +class TestTokenAuth: + def test_sync_sets_bearer_header(self) -> None: + with req_mock.Mocker() as m: + m.post(_TOKEN_URL, json={"access_token": "gw-token", "expires_in": 3600}) + auth, _ = _make_token_auth() + prepared = _apply_auth(auth) + assert prepared.headers["Authorization"] == "Bearer gw-token" + + def test_sync_caches_token(self) -> None: + with req_mock.Mocker() as m: + m.post(_TOKEN_URL, json={"access_token": "gw-token", "expires_in": 3600}) + auth, source = _make_token_auth() + _apply_auth(auth) + _apply_auth(auth) + assert source.call_count == 1 + + def test_sync_refreshes_expired_token(self) -> None: + with req_mock.Mocker() as m: + m.post(_TOKEN_URL, json={"access_token": "gw-token", "expires_in": 3600}) + auth, source = _make_token_auth() + auth._token = Token(id_token="old", expiry=_PAST) + _apply_auth(auth) + assert source.call_count == 1 + + def test_token_returns_string(self) -> None: + with req_mock.Mocker() as m: + m.post(_TOKEN_URL, json={"access_token": "gw-token", "expires_in": 3600}) + auth, _ = _make_token_auth() + assert auth.token() == "gw-token" + + def test_satisfies_token_source_protocol(self) -> None: + auth = TokenAuth(token_url=_TOKEN_URL, token_source=_FakeTokenSource()) + assert isinstance(auth, TokenSource) + + def test_is_requests_auth(self) -> None: + import requests.auth + + auth = TokenAuth(token_url=_TOKEN_URL, token_source=_FakeTokenSource()) + assert isinstance(auth, requests.auth.AuthBase) + + def test_raises_oauth_error_on_bad_exchange(self) -> None: + with req_mock.Mocker() as m: + m.post( + _TOKEN_URL, + status_code=400, + json={"error": "invalid_grant", "error_description": "upstream token rejected"}, + ) + auth = TokenAuth(token_url=_TOKEN_URL, token_source=_FakeTokenSource()) + with pytest.raises(OAuthError, match="invalid_grant"): + auth.token() + + def test_repr(self) -> None: + auth = TokenAuth(token_url=_TOKEN_URL, token_source=_FakeTokenSource()) + assert "TokenAuth" in repr(auth) + assert _TOKEN_URL in repr(auth) diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..6b17442 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,117 @@ +"""Tests for Pydantic models.""" + +from __future__ import annotations + +from openfaas.models import ( + FunctionDeployment, + FunctionNamespace, + FunctionResources, + FunctionStatus, + LogMessage, + Secret, + SystemInfo, +) + + +class TestFunctionDeployment: + def test_minimal(self) -> None: + spec = FunctionDeployment(service="env", image="ghcr.io/openfaas/env:latest") + assert spec.service == "env" + assert spec.image == "ghcr.io/openfaas/env:latest" + assert spec.namespace is None + + def test_to_api_dict_excludes_none(self) -> None: + spec = FunctionDeployment(service="env", image="ghcr.io/openfaas/env:latest") + d = spec.to_api_dict() + assert "namespace" not in d + assert d["service"] == "env" + + def test_to_api_dict_uses_camel_aliases(self) -> None: + spec = FunctionDeployment( + service="env", + image="ghcr.io/openfaas/env:latest", + env_vars={"KEY": "VALUE"}, + read_only_root_filesystem=True, + ) + d = spec.to_api_dict() + assert "envVars" in d + assert "readOnlyRootFilesystem" in d + assert "env_vars" not in d + + def test_with_resources(self) -> None: + spec = FunctionDeployment( + service="env", + image="ghcr.io/openfaas/env:latest", + limits=FunctionResources(memory="128Mi", cpu="100m"), + ) + d = spec.to_api_dict() + assert d["limits"] == {"memory": "128Mi", "cpu": "100m"} + + +class TestFunctionStatus: + def test_parse_from_api_response(self) -> None: + data = { + "name": "env", + "image": "ghcr.io/openfaas/env:latest", + "namespace": "openfaas-fn", + "invocationCount": 42.0, + "replicas": 1, + "availableReplicas": 1, + } + status = FunctionStatus.model_validate(data) + assert status.name == "env" + assert status.invocation_count == 42.0 + assert status.replicas == 1 + + def test_missing_optional_fields_default(self) -> None: + status = FunctionStatus.model_validate({"name": "env"}) + assert status.image == "" + assert status.replicas == 0 + assert status.usage is None + + +class TestFunctionNamespace: + def test_to_api_dict(self) -> None: + ns = FunctionNamespace(name="staging", labels={"team": "backend"}) + d = ns.to_api_dict() + assert d["name"] == "staging" + assert d["labels"] == {"team": "backend"} + assert "annotations" not in d + + +class TestSecret: + def test_to_api_dict_excludes_none(self) -> None: + s = Secret(name="my-secret", namespace="openfaas-fn", value="s3cr3t") + d = s.to_api_dict() + assert d == {"name": "my-secret", "namespace": "openfaas-fn", "value": "s3cr3t"} + + def test_to_api_dict_no_value(self) -> None: + s = Secret(name="my-secret") + d = s.to_api_dict() + assert "value" not in d + assert "namespace" not in d + + +class TestSystemInfo: + def test_parse_empty(self) -> None: + info = SystemInfo.model_validate({}) + assert info.arch == "" + + def test_parse_full(self) -> None: + data = { + "arch": "amd64", + "provider": {"provider": "faas", "orchestration": "kubernetes"}, + "version": {"release": "0.27.0"}, + } + info = SystemInfo.model_validate(data) + assert info.arch == "amd64" + assert info.provider.orchestration == "kubernetes" + assert info.version.release == "0.27.0" + + +class TestLogMessage: + def test_parse_ndjson(self) -> None: + line = '{"name":"env","namespace":"openfaas-fn","instance":"env-xxx","text":"hello"}' + msg = LogMessage.model_validate_json(line) + assert msg.name == "env" + assert msg.text == "hello" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..091427b --- /dev/null +++ b/uv.lock @@ -0,0 +1,530 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[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 = "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 = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, + { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, + { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "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 = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "idna" +version = "3.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/12/2948fbe5513d062169bd91f7d7b1cd97bc8894f32946b71fa39f6e63ca0c/idna-3.12.tar.gz", hash = "sha256:724e9952cc9e2bd7550ea784adb098d837ab5267ef67a1ab9cf7846bdbdd8254", size = 194350, upload-time = "2026-04-21T13:32:48.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/b2/acc33950394b3becb2b664741a0c0889c7ef9f9ffbfa8d47eddb53a50abd/idna-3.12-py3-none-any.whl", hash = "sha256:60ffaa1858fac94c9c124728c24fcde8160f3fb4a7f79aa8cdd33a9d1af60a67", size = 68634, upload-time = "2026-04-21T13:32:47.403Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "openfaas-sdk" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "pydantic" }, + { name = "requests" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "requests-mock" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "pydantic", specifier = ">=2.0.0,<3" }, + { name = "requests", specifier = ">=2.20.0,<3" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.408" }, + { name = "pytest", specifier = ">=9.0.3" }, + { name = "requests-mock", specifier = ">=1.12.1" }, + { name = "ruff", specifier = ">=0.15.11" }, +] + +[[package]] +name = "packaging" +version = "26.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.3" +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/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/98/b50eb9a411e87483b5c65dba4fa430a06bac4234d3403a40e5a9905ebcd0/pydantic_core-2.46.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:1da3786b8018e60349680720158cc19161cc3b4bdd815beb0a321cd5ce1ad5b1", size = 2108971, upload-time = "2026-04-20T14:43:51.945Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f364b9d161718ff2217160a4b5d41ce38de60aed91c3689ebffa1c939d23/pydantic_core-2.46.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cc0988cb29d21bf4a9d5cf2ef970b5c0e38d8d8e107a493278c05dc6c1dda69f", size = 1949588, upload-time = "2026-04-20T14:44:10.386Z" }, + { url = "https://files.pythonhosted.org/packages/8f/8b/30bd03ee83b2f5e29f5ba8e647ab3c456bf56f2ec72fdbcc0215484a0854/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27f9067c3bfadd04c55484b89c0d267981b2f3512850f6f66e1e74204a4e4ce3", size = 1975986, upload-time = "2026-04-20T14:43:57.106Z" }, + { url = "https://files.pythonhosted.org/packages/3c/54/13ccf954d84ec275d5d023d5786e4aa48840bc9f161f2838dc98e1153518/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a642ac886ecf6402d9882d10c405dcf4b902abeb2972cd5fb4a48c83cd59279a", size = 2055830, upload-time = "2026-04-20T14:44:15.499Z" }, + { url = "https://files.pythonhosted.org/packages/be/0e/65f38125e660fdbd72aa858e7dfae893645cfa0e7b13d333e174a367cd23/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79f561438481f28681584b89e2effb22855e2179880314bcddbf5968e935e807", size = 2222340, upload-time = "2026-04-20T14:41:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/d1/88/f3ab7739efe0e7e80777dbb84c59eb98518e3f57ea433206194c2e425272/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57a973eae4665352a47cf1a99b4ee864620f2fe663a217d7a8da68a1f3a5bfda", size = 2280727, upload-time = "2026-04-20T14:41:30.461Z" }, + { url = "https://files.pythonhosted.org/packages/2a/6d/c228219080817bec4982f9531cadb18da6aaa770fdeb114f49c237ac2c9f/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83d002b97072a53ea150d63e0a3adfae5670cef5aa8a6e490240e482d3b22e57", size = 2092158, upload-time = "2026-04-20T14:44:07.305Z" }, + { url = "https://files.pythonhosted.org/packages/0f/b1/525a16711e7c6d61635fac3b0bd54600b5c5d9f60c6fc5aaab26b64a2297/pydantic_core-2.46.3-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b40ddd51e7c44b28cfaef746c9d3c506d658885e0a46f9eeef2ee815cbf8e045", size = 2116626, upload-time = "2026-04-20T14:42:34.118Z" }, + { url = "https://files.pythonhosted.org/packages/ef/7c/17d30673351439a6951bf54f564cf2443ab00ae264ec9df00e2efd710eb5/pydantic_core-2.46.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ac5ec7fb9b87f04ee839af2d53bcadea57ded7d229719f56c0ed895bff987943", size = 2160691, upload-time = "2026-04-20T14:41:14.023Z" }, + { url = "https://files.pythonhosted.org/packages/86/66/af8adbcbc0886ead7f1a116606a534d75a307e71e6e08226000d51b880d2/pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a3b11c812f61b3129c4905781a2601dfdfdea5fe1e6c1cfb696b55d14e9c054f", size = 2182543, upload-time = "2026-04-20T14:40:48.886Z" }, + { url = "https://files.pythonhosted.org/packages/b0/37/6de71e0f54c54a4190010f57deb749e1ddf75c568ada3b1320b70067f121/pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:1108da631e602e5b3c38d6d04fe5bb3bfa54349e6918e3ca6cf570b2e2b2f9d4", size = 2324513, upload-time = "2026-04-20T14:42:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/51/b1/9fc74ce94f603d5ef59ff258ca9c2c8fb902fb548d340a96f77f4d1c3b7f/pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:de885175515bcfa98ae618c1df7a072f13d179f81376c8007112af20567fd08a", size = 2361853, upload-time = "2026-04-20T14:43:24.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/d0/4c652fc592db35f100279ee751d5a145aca1b9a7984b9684ba7c1b5b0535/pydantic_core-2.46.3-cp310-cp310-win32.whl", hash = "sha256:d11058e3201527d41bc6b545c79187c9e4bf85e15a236a6007f0e991518882b7", size = 1980465, upload-time = "2026-04-20T14:44:46.239Z" }, + { url = "https://files.pythonhosted.org/packages/27/b8/a920453c38afbe1f355e1ea0b0d94a0a3e0b0879d32d793108755fa171d5/pydantic_core-2.46.3-cp310-cp310-win_amd64.whl", hash = "sha256:3612edf65c8ea67ac13616c4d23af12faef1ae435a8a93e5934c2a0cbbdd1fd6", size = 2073884, upload-time = "2026-04-20T14:43:01.201Z" }, + { url = "https://files.pythonhosted.org/packages/22/a2/1ba90a83e85a3f94c796b184f3efde9c72f2830dcda493eea8d59ba78e6d/pydantic_core-2.46.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ab124d49d0459b2373ecf54118a45c28a1e6d4192a533fbc915e70f556feb8e5", size = 2106740, upload-time = "2026-04-20T14:41:20.932Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f6/99ae893c89a0b9d3daec9f95487aa676709aa83f67643b3f0abaf4ab628a/pydantic_core-2.46.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cca67d52a5c7a16aed2b3999e719c4bcf644074eac304a5d3d62dd70ae7d4b2c", size = 1948293, upload-time = "2026-04-20T14:43:42.115Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b8/2e8e636dc9e3f16c2e16bf0849e24be82c5ee82c603c65fc0326666328fc/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c024e08c0ba23e6fd68c771a521e9d6a792f2ebb0fa734296b36394dc30390e", size = 1973222, upload-time = "2026-04-20T14:41:57.841Z" }, + { url = "https://files.pythonhosted.org/packages/34/36/0e730beec4d83c5306f417afbd82ff237d9a21e83c5edf675f31ed84c1fe/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6645ce7eec4928e29a1e3b3d5c946621d105d3e79f0c9cddf07c2a9770949287", size = 2053852, upload-time = "2026-04-20T14:40:43.077Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f0/3071131f47e39136a17814576e0fada9168569f7f8c0e6ac4d1ede6a4958/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a712c7118e6c5ea96562f7b488435172abb94a3c53c22c9efc1412264a45cbbe", size = 2221134, upload-time = "2026-04-20T14:43:03.349Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a9/a2dc023eec5aa4b02a467874bad32e2446957d2adcab14e107eab502e978/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69a868ef3ff206343579021c40faf3b1edc64b1cc508ff243a28b0a514ccb050", size = 2279785, upload-time = "2026-04-20T14:41:19.285Z" }, + { url = "https://files.pythonhosted.org/packages/0a/44/93f489d16fb63fbd41c670441536541f6e8cfa1e5a69f40bc9c5d30d8c90/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc7e8c32db809aa0f6ea1d6869ebc8518a65d5150fdfad8bcae6a49ae32a22e2", size = 2089404, upload-time = "2026-04-20T14:43:10.108Z" }, + { url = "https://files.pythonhosted.org/packages/2a/78/8692e3aa72b2d004f7a5d937f1dfdc8552ba26caf0bec75f342c40f00dec/pydantic_core-2.46.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3481bd1341dc85779ee506bc8e1196a277ace359d89d28588a9468c3ecbe63fa", size = 2114898, upload-time = "2026-04-20T14:44:51.475Z" }, + { url = "https://files.pythonhosted.org/packages/6a/62/e83133f2e7832532060175cebf1f13748f4c7e7e7165cdd1f611f174494b/pydantic_core-2.46.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8690eba565c6d68ffd3a8655525cbdd5246510b44a637ee2c6c03a7ebfe64d3c", size = 2157856, upload-time = "2026-04-20T14:43:46.64Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ec/6a500e3ad7718ee50583fae79c8651f5d37e3abce1fa9ae177ae65842c53/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4de88889d7e88d50d40ee5b39d5dac0bcaef9ba91f7e536ac064e6b2834ecccf", size = 2180168, upload-time = "2026-04-20T14:42:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/d8/53/8267811054b1aa7fc1dc7ded93812372ef79a839f5e23558136a6afbfde1/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:e480080975c1ef7f780b8f99ed72337e7cc5efea2e518a20a692e8e7b278eb8b", size = 2322885, upload-time = "2026-04-20T14:41:05.253Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c1/1c0acdb3aa0856ddc4ecc55214578f896f2de16f400cf51627eb3c26c1c4/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:de3a5c376f8cd94da9a1b8fd3dd1c16c7a7b216ed31dc8ce9fd7a22bf13b836e", size = 2360328, upload-time = "2026-04-20T14:41:43.991Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d0/ef39cd0f4a926814f360e71c1adeab48ad214d9727e4deb48eedfb5bce1a/pydantic_core-2.46.3-cp311-cp311-win32.whl", hash = "sha256:fc331a5314ffddd5385b9ee9d0d2fee0b13c27e0e02dad71b1ae5d6561f51eeb", size = 1979464, upload-time = "2026-04-20T14:43:12.215Z" }, + { url = "https://files.pythonhosted.org/packages/18/9c/f41951b0d858e343f1cf09398b2a7b3014013799744f2c4a8ad6a3eec4f2/pydantic_core-2.46.3-cp311-cp311-win_amd64.whl", hash = "sha256:b5b9c6cf08a8a5e502698f5e153056d12c34b8fb30317e0c5fd06f45162a6346", size = 2070837, upload-time = "2026-04-20T14:41:47.707Z" }, + { url = "https://files.pythonhosted.org/packages/9f/1e/264a17cd582f6ed50950d4d03dd5fefd84e570e238afe1cb3e25cf238769/pydantic_core-2.46.3-cp311-cp311-win_arm64.whl", hash = "sha256:5dfd51cf457482f04ec49491811a2b8fd5b843b64b11eecd2d7a1ee596ea78a6", size = 2053647, upload-time = "2026-04-20T14:42:27.535Z" }, + { url = "https://files.pythonhosted.org/packages/4b/cb/5b47425556ecc1f3fe18ed2a0083188aa46e1dd812b06e406475b3a5d536/pydantic_core-2.46.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67", size = 2101946, upload-time = "2026-04-20T14:40:52.581Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/2fb62c2267cae99b815bbf4a7b9283812c88ca3153ef29f7707200f1d4e5/pydantic_core-2.46.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089", size = 1951612, upload-time = "2026-04-20T14:42:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/50/6e/b7348fd30d6556d132cddd5bd79f37f96f2601fe0608afac4f5fb01ec0b3/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0", size = 1977027, upload-time = "2026-04-20T14:42:02.001Z" }, + { url = "https://files.pythonhosted.org/packages/82/11/31d60ee2b45540d3fb0b29302a393dbc01cd771c473f5b5147bcd353e593/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789", size = 2063008, upload-time = "2026-04-20T14:44:17.952Z" }, + { url = "https://files.pythonhosted.org/packages/8a/db/3a9d1957181b59258f44a2300ab0f0be9d1e12d662a4f57bb31250455c52/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d", size = 2233082, upload-time = "2026-04-20T14:40:57.934Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e1/3277c38792aeb5cfb18c2f0c5785a221d9ff4e149abbe1184d53d5f72273/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c", size = 2304615, upload-time = "2026-04-20T14:42:12.584Z" }, + { url = "https://files.pythonhosted.org/packages/5e/d5/e3d9717c9eba10855325650afd2a9cba8e607321697f18953af9d562da2f/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395", size = 2094380, upload-time = "2026-04-20T14:43:05.522Z" }, + { url = "https://files.pythonhosted.org/packages/a1/20/abac35dedcbfd66c6f0b03e4e3564511771d6c9b7ede10a362d03e110d9b/pydantic_core-2.46.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396", size = 2135429, upload-time = "2026-04-20T14:41:55.549Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a5/41bfd1df69afad71b5cf0535055bccc73022715ad362edbc124bc1e021d7/pydantic_core-2.46.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d", size = 2174582, upload-time = "2026-04-20T14:41:45.96Z" }, + { url = "https://files.pythonhosted.org/packages/79/65/38d86ea056b29b2b10734eb23329b7a7672ca604df4f2b6e9c02d4ee22fe/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca", size = 2187533, upload-time = "2026-04-20T14:40:55.367Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/a1129141678a2026badc539ad1dee0a71d06f54c2f06a4bd68c030ac781b/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976", size = 2332985, upload-time = "2026-04-20T14:44:13.05Z" }, + { url = "https://files.pythonhosted.org/packages/d7/60/cb26f4077719f709e54819f4e8e1d43f4091f94e285eb6bd21e1190a7b7c/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b", size = 2373670, upload-time = "2026-04-20T14:41:53.421Z" }, + { url = "https://files.pythonhosted.org/packages/6b/7e/c3f21882bdf1d8d086876f81b5e296206c69c6082551d776895de7801fa0/pydantic_core-2.46.3-cp312-cp312-win32.whl", hash = "sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4", size = 1966722, upload-time = "2026-04-20T14:44:30.588Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/6b5e757b859013ebfbd7adba02f23b428f37c86dcbf78b5bb0b4ffd36e99/pydantic_core-2.46.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1", size = 2072970, upload-time = "2026-04-20T14:42:54.248Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f8/a989b21cc75e9a32d24192ef700eea606521221a89faa40c919ce884f2b1/pydantic_core-2.46.3-cp312-cp312-win_arm64.whl", hash = "sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72", size = 2035963, upload-time = "2026-04-20T14:44:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3c/9b5e8eb9821936d065439c3b0fb1490ffa64163bfe7e1595985a47896073/pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37", size = 2102109, upload-time = "2026-04-20T14:41:24.219Z" }, + { url = "https://files.pythonhosted.org/packages/91/97/1c41d1f5a19f241d8069f1e249853bcce378cdb76eec8ab636d7bc426280/pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f", size = 1951820, upload-time = "2026-04-20T14:42:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/30/b4/d03a7ae14571bc2b6b3c7b122441154720619afe9a336fa3a95434df5e2f/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8", size = 1977785, upload-time = "2026-04-20T14:42:31.648Z" }, + { url = "https://files.pythonhosted.org/packages/ae/0c/4086f808834b59e3c8f1aa26df8f4b6d998cdcf354a143d18ef41529d1fe/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad", size = 2062761, upload-time = "2026-04-20T14:40:37.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/a649be5a5064c2df0db06e0a512c2281134ed2fcc981f52a657936a7527c/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c", size = 2232989, upload-time = "2026-04-20T14:42:59.254Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/7756e75763e810b3a710f4724441d1ecc5883b94aacb07ca71c5fb5cfb69/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f", size = 2303975, upload-time = "2026-04-20T14:41:32.287Z" }, + { url = "https://files.pythonhosted.org/packages/6c/35/68a762e0c1e31f35fa0dac733cbd9f5b118042853698de9509c8e5bf128b/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35", size = 2095325, upload-time = "2026-04-20T14:42:47.685Z" }, + { url = "https://files.pythonhosted.org/packages/77/bf/1bf8c9a8e91836c926eae5e3e51dce009bf495a60ca56060689d3df3f340/pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687", size = 2133368, upload-time = "2026-04-20T14:41:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/e5/50/87d818d6bab915984995157ceb2380f5aac4e563dddbed6b56f0ed057aba/pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3", size = 2173908, upload-time = "2026-04-20T14:42:52.044Z" }, + { url = "https://files.pythonhosted.org/packages/91/88/a311fb306d0bd6185db41fa14ae888fb81d0baf648a761ae760d30819d33/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022", size = 2186422, upload-time = "2026-04-20T14:43:29.55Z" }, + { url = "https://files.pythonhosted.org/packages/8f/79/28fd0d81508525ab2054fef7c77a638c8b5b0afcbbaeee493cf7c3fef7e1/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23", size = 2332709, upload-time = "2026-04-20T14:42:16.134Z" }, + { url = "https://files.pythonhosted.org/packages/b3/21/795bf5fe5c0f379308b8ef19c50dedab2e7711dbc8d0c2acf08f1c7daa05/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7", size = 2372428, upload-time = "2026-04-20T14:41:10.974Z" }, + { url = "https://files.pythonhosted.org/packages/45/b3/ed14c659cbe7605e3ef063077680a64680aec81eb1a04763a05190d49b7f/pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13", size = 1965601, upload-time = "2026-04-20T14:41:42.128Z" }, + { url = "https://files.pythonhosted.org/packages/ef/bb/adb70d9a762ddd002d723fbf1bd492244d37da41e3af7b74ad212609027e/pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0", size = 2071517, upload-time = "2026-04-20T14:43:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/52/eb/66faefabebfe68bd7788339c9c9127231e680b11906368c67ce112fdb47f/pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec", size = 2035802, upload-time = "2026-04-20T14:43:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/7f/db/a7bcb4940183fda36022cd18ba8dd12f2dff40740ec7b58ce7457befa416/pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", size = 2097614, upload-time = "2026-04-20T14:44:38.374Z" }, + { url = "https://files.pythonhosted.org/packages/24/35/e4066358a22e3e99519db370494c7528f5a2aa1367370e80e27e20283543/pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", size = 1951896, upload-time = "2026-04-20T14:40:53.996Z" }, + { url = "https://files.pythonhosted.org/packages/87/92/37cf4049d1636996e4b888c05a501f40a43ff218983a551d57f9d5e14f0d/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", size = 1979314, upload-time = "2026-04-20T14:41:49.446Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/9ff4d676dfbdfb2d591cf43f3d90ded01e15b1404fd101180ed2d62a2fd3/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", size = 2056133, upload-time = "2026-04-20T14:42:23.574Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f0/405b442a4d7ba855b06eec8b2bf9c617d43b8432d099dfdc7bf999293495/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", size = 2228726, upload-time = "2026-04-20T14:44:22.816Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f8/65cd92dd5a0bd89ba277a98ecbfaf6fc36bbd3300973c7a4b826d6ab1391/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", size = 2301214, upload-time = "2026-04-20T14:44:48.792Z" }, + { url = "https://files.pythonhosted.org/packages/fd/86/ef96a4c6e79e7a2d0410826a68fbc0eccc0fd44aa733be199d5fcac3bb87/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", size = 2099927, upload-time = "2026-04-20T14:41:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/6d/53/269caf30e0096e0a8a8f929d1982a27b3879872cca2d917d17c2f9fdf4fe/pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", size = 2128789, upload-time = "2026-04-20T14:41:15.868Z" }, + { url = "https://files.pythonhosted.org/packages/00/b0/1a6d9b6a587e118482910c244a1c5acf4d192604174132efd12bf0ac486f/pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", size = 2173815, upload-time = "2026-04-20T14:44:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/87/56/e7e00d4041a7e62b5a40815590114db3b535bf3ca0bf4dca9f16cef25246/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", size = 2181608, upload-time = "2026-04-20T14:41:28.933Z" }, + { url = "https://files.pythonhosted.org/packages/e8/22/4bd23c3d41f7c185d60808a1de83c76cf5aeabf792f6c636a55c3b1ec7f9/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", size = 2326968, upload-time = "2026-04-20T14:42:03.962Z" }, + { url = "https://files.pythonhosted.org/packages/24/ac/66cd45129e3915e5ade3b292cb3bc7fd537f58f8f8dbdaba6170f7cabb74/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", size = 2369842, upload-time = "2026-04-20T14:41:35.52Z" }, + { url = "https://files.pythonhosted.org/packages/a2/51/dd4248abb84113615473aa20d5545b7c4cd73c8644003b5259686f93996c/pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", size = 1959661, upload-time = "2026-04-20T14:41:00.042Z" }, + { url = "https://files.pythonhosted.org/packages/20/eb/59980e5f1ae54a3b86372bd9f0fa373ea2d402e8cdcd3459334430f91e91/pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", size = 2071686, upload-time = "2026-04-20T14:43:16.471Z" }, + { url = "https://files.pythonhosted.org/packages/8c/db/1cf77e5247047dfee34bc01fa9bca134854f528c8eb053e144298893d370/pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", size = 2026907, upload-time = "2026-04-20T14:43:31.732Z" }, + { url = "https://files.pythonhosted.org/packages/57/c0/b3df9f6a543276eadba0a48487b082ca1f201745329d97dbfa287034a230/pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", size = 2095047, upload-time = "2026-04-20T14:42:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/57/886a938073b97556c168fd99e1a7305bb363cd30a6d2c76086bf0587b32a/pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", size = 1934329, upload-time = "2026-04-20T14:43:49.655Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7c/b42eaa5c34b13b07ecb51da21761297a9b8eb43044c864a035999998f328/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", size = 1974847, upload-time = "2026-04-20T14:42:10.737Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9b/92b42db6543e7de4f99ae977101a2967b63122d4b6cf7773812da2d7d5b5/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", size = 2041742, upload-time = "2026-04-20T14:40:44.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/19/46fbe1efabb5aa2834b43b9454e70f9a83ad9c338c1291e48bdc4fecf167/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", size = 2236235, upload-time = "2026-04-20T14:41:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/77/da/b3f95bc009ad60ec53120f5d16c6faa8cabdbe8a20d83849a1f2b8728148/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", size = 2282633, upload-time = "2026-04-20T14:44:33.271Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6e/401336117722e28f32fb8220df676769d28ebdf08f2f4469646d404c43a3/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", size = 2109679, upload-time = "2026-04-20T14:44:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/fc/53/b289f9bc8756a32fe718c46f55afaeaf8d489ee18d1a1e7be1db73f42cc4/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", size = 2108342, upload-time = "2026-04-20T14:42:50.144Z" }, + { url = "https://files.pythonhosted.org/packages/10/5b/8292fc7c1f9111f1b2b7c1b0dcf1179edcd014fc3ea4517499f50b829d71/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", size = 2157208, upload-time = "2026-04-20T14:42:08.133Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/f80044e9ec07580f057a89fc131f78dda7a58751ddf52bbe05eaf31db50f/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", size = 2167237, upload-time = "2026-04-20T14:42:25.412Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/6781a1b037f3b96be9227edbd1101f6d3946746056231bf4ac48cdff1a8d/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", size = 2312540, upload-time = "2026-04-20T14:40:40.313Z" }, + { url = "https://files.pythonhosted.org/packages/3e/db/19c0839feeb728e7df03255581f198dfdf1c2aeb1e174a8420b63c5252e5/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", size = 2369556, upload-time = "2026-04-20T14:41:09.427Z" }, + { url = "https://files.pythonhosted.org/packages/e0/15/3228774cb7cd45f5f721ddf1b2242747f4eb834d0c491f0c02d606f09fed/pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56", size = 1949756, upload-time = "2026-04-20T14:41:25.717Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2a/c79cf53fd91e5a87e30d481809f52f9a60dd221e39de66455cf04deaad37/pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", size = 2051305, upload-time = "2026-04-20T14:43:18.627Z" }, + { url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/66/7f/03dbad45cd3aa9083fbc93c210ae8b005af67e4136a14186950a747c6874/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:9715525891ed524a0a1eb6d053c74d4d4ad5017677fb00af0b7c2644a31bae46", size = 2105683, upload-time = "2026-04-20T14:42:19.779Z" }, + { url = "https://files.pythonhosted.org/packages/26/22/4dc186ac8ea6b257e9855031f51b62a9637beac4d68ac06bee02f046f836/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:9d2f400712a99a013aff420ef1eb9be077f8189a36c1e3ef87660b4e1088a874", size = 1940052, upload-time = "2026-04-20T14:43:59.274Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ca/d376391a5aff1f2e8188960d7873543608130a870961c2b6b5236627c116/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd2aab0e2e9dc2daf36bd2686c982535d5e7b1d930a1344a7bb6e82baab42a76", size = 1988172, upload-time = "2026-04-20T14:41:17.469Z" }, + { url = "https://files.pythonhosted.org/packages/0e/6b/523b9f85c23788755d6ab949329de692a2e3a584bc6beb67fef5e035aa9d/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e9d76736da5f362fabfeea6a69b13b7f2be405c6d6966f06b2f6bfff7e64531", size = 2128596, upload-time = "2026-04-20T14:40:41.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/42/f426db557e8ab2791bc7562052299944a118655496fbff99914e564c0a94/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803", size = 2091877, upload-time = "2026-04-20T14:43:27.091Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4f/86a832a9d14df58e663bfdf4627dc00d3317c2bd583c4fb23390b0f04b8e/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3", size = 1932428, upload-time = "2026-04-20T14:40:45.781Z" }, + { url = "https://files.pythonhosted.org/packages/11/1a/fe857968954d93fb78e0d4b6df5c988c74c4aaa67181c60be7cfe327c0ca/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5", size = 1997550, upload-time = "2026-04-20T14:44:02.425Z" }, + { url = "https://files.pythonhosted.org/packages/17/eb/9d89ad2d9b0ba8cd65393d434471621b98912abb10fbe1df08e480ba57b5/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4", size = 2137657, upload-time = "2026-04-20T14:42:45.149Z" }, + { url = "https://files.pythonhosted.org/packages/1f/da/99d40830684f81dec901cac521b5b91c095394cc1084b9433393cde1c2df/pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:13afdd885f3d71280cf286b13b310ee0f7ccfefd1dbbb661514a474b726e2f25", size = 2107973, upload-time = "2026-04-20T14:42:06.175Z" }, + { url = "https://files.pythonhosted.org/packages/99/a5/87024121818d75bbb2a98ddbaf638e40e7a18b5e0f5492c9ca4b1b316107/pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f91c0aff3e3ee0928edd1232c57f643a7a003e6edf1860bc3afcdc749cb513f3", size = 1947191, upload-time = "2026-04-20T14:43:14.319Z" }, + { url = "https://files.pythonhosted.org/packages/60/62/0c1acfe10945b83a6a59d19fbaa92f48825381509e5701b855c08f13db76/pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6529d1d128321a58d30afcc97b49e98836542f68dd41b33c2e972bb9e5290536", size = 2123791, upload-time = "2026-04-20T14:43:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/3b2393b4c8f44285561dc30b00cf307a56a2eff7c483a824db3b8221ca51/pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:975c267cff4f7e7272eacbe50f6cc03ca9a3da4c4fbd66fffd89c94c1e311aa1", size = 2153197, upload-time = "2026-04-20T14:44:27.932Z" }, + { url = "https://files.pythonhosted.org/packages/ba/75/5af02fb35505051eee727c061f2881c555ab4f8ddb2d42da715a42c9731b/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2b8e4f2bbdf71415c544b4b1138b8060db7b6611bc927e8064c769f64bed651c", size = 2181073, upload-time = "2026-04-20T14:43:20.729Z" }, + { url = "https://files.pythonhosted.org/packages/10/92/7e0e1bd9ca3c68305db037560ca2876f89b2647deb2f8b6319005de37505/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e61ea8e9fff9606d09178f577ff8ccdd7206ff73d6552bcec18e1033c4254b85", size = 2315886, upload-time = "2026-04-20T14:44:04.826Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d8/101655f27eaf3e44558ead736b2795d12500598beed4683f279396fa186e/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b504bda01bafc69b6d3c7a0c7f039dcf60f47fab70e06fe23f57b5c75bdc82b8", size = 2360528, upload-time = "2026-04-20T14:40:47.431Z" }, + { url = "https://files.pythonhosted.org/packages/07/0f/1c34a74c8d07136f0d729ffe5e1fdab04fbdaa7684f61a92f92511a84a15/pydantic_core-2.46.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b00b76f7142fc60c762ce579bd29c8fa44aaa56592dd3c54fab3928d0d4ca6ff", size = 2184144, upload-time = "2026-04-20T14:42:57Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.408" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +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/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "requests-mock" +version = "1.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/32/587625f91f9a0a3d84688bf9cfc4b2480a7e8ec327cefd0ff2ac891fd2cf/requests-mock-1.12.1.tar.gz", hash = "sha256:e9e12e333b525156e82a3c852f22016b9158220d2f47454de9cae8a77d371401", size = 60901, upload-time = "2024-03-29T03:54:29.446Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/ec/889fbc557727da0c34a33850950310240f2040f3b1955175fdb2b36a8910/requests_mock-1.12.1-py2.py3-none-any.whl", hash = "sha256:b1e37054004cdd5e56c84454cc7df12b25f90f382159087f4b6915aaeef39563", size = 27695, upload-time = "2024-03-29T03:54:27.64Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" }, + { url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" }, + { url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" }, + { url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" }, + { url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" }, + { url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" }, + { url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" }, + { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "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 = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +]