From b7a129160180a20346cade14f7f604b10fb57af7 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Wed, 6 May 2026 13:41:19 -0700 Subject: [PATCH 01/63] initial add of code from vegbank module, minor changes to get tests passing --- pyproject.toml | 7 +- src/dataone/auth.py | 606 +++++++++++++++++++++++++++++++++++++++++++- tests/test_auth.py | 38 ++- uv.lock | 366 ++++++++++++++++++++++++++ 4 files changed, 997 insertions(+), 20 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a0fccfa..0637e9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,12 @@ authors = [ { name = "Matthew B. Jones", email = "jones@nceas.ucsb.edu" } ] requires-python = ">=3.13" -dependencies = [] +dependencies = [ + "authlib>=1.7.2", + "flask>=3.1.3", + "requests>=2.33.1", + "werkzeug>=3.1.8", +] [project.scripts] dataone = "dataone:main" diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 082564e..ed51503 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -2,22 +2,616 @@ Implements OIDC / OAuth 2.0 login via a configurable OIDC provider using authlib. +Deployment Modes +---------------- +The API supports three access modes controlled by the ``VB_ACCESS_MODE`` environment variable: + +``read_only`` + Authentication disabled. All endpoints are public. File uploads disabled. + +``open`` + Authentication disabled. All endpoints are public. File uploads allowed. + +``authenticated`` + Full authentication and authorization enabled. Protected endpoints require valid JWT tokens with appropriate scopes. + +Decorator overview +-------------------------------------------------- +``require_token`` + Protects an endpoint that requires *any* valid, unexpired JWT issued by + the configured OIDC provider. + +``require_scope(scope)`` + Same as ``require_token`` but additionally asserts that the token contains the + correct Vegbank scope (e.g. ``"vegbank:admin"``, ``"vegbank:contributor"``, + ``"vegbank:user"``). + """ +import functools +import json import logging +import os +import re +from requests import RequestException + +import requests as _requests + +from authlib.integrations.base_client.errors import OAuthError +from authlib.integrations.flask_client import OAuth +from authlib.jose import JsonWebKey, jwt +from authlib.jose.errors import BadSignatureError, DecodeError, InvalidTokenError +from authlib.oauth2 import OAuth2Error +from authlib.oauth2.rfc6749.errors import InvalidGrantError, InvalidClientError + +from flask import Blueprint, g, jsonify, request, url_for +from werkzeug.middleware.proxy_fix import ProxyFix + +_DEFAULT_SECRETS_PATH = "/etc/vegbank/oidc/client_secrets.json" +MAX_TOKEN_LEN = 16_384 # Token length limit in characters (~16 KB) to prevent DoS attacks + +class MissingParameterError(Exception): + """Raised when a required request parameter is missing.""" + +# Standard OIDC scopes — overridable via environment variable +DEFAULT_SCOPES = os.getenv("VB_OIDC_DEFAULT_SCOPES", "openid email profile") + +# VegBank-specific scopes — configurable via environment variables set by Helm +SCOPE_ADMIN = os.getenv("VB_SCOPE_ADMIN", "vegbank:admin") +SCOPE_CONTRIBUTOR = os.getenv("VB_SCOPE_CONTRIBUTOR", "vegbank:contributor") +SCOPE_USER = os.getenv("VB_SCOPE_USER", "vegbank:user") + +# Deployment modes +ACCESS_MODE_READ_ONLY = "read_only" # Read-only mode: no uploads, no auth +ACCESS_MODE_OPEN = "open" # Open mode: uploads allowed, no auth +ACCESS_MODE_AUTHENTICATED = "authenticated" # Authenticated mode: auth required, full access control # Initialize module-level logger logger = logging.getLogger(__name__) +oauth = OAuth() +auth_bp = Blueprint("auth", __name__) + + +def load_client_secrets(filepath: str | None = None) -> dict: + """Load client secrets from a JSON file. + + Args: + filepath: Optional explicit path. Falls back to the + ``OIDC_CLIENT_SECRETS_FILE`` environment variable + + Returns: + Parsed dict of client credentials. + """ + # accept either explicit filepath argument or environment variable, with a default fallback + resolved = ( + filepath + or os.getenv("OIDC_CLIENT_SECRETS_FILE") + or _DEFAULT_SECRETS_PATH + ) + with open(resolved, "r") as f: + return json.load(f) + + +def init_oauth(app) -> bool: + """Initialise the OAuth client and register the OIDC provider. + + Call once at app startup, after creating the Flask instance. + + Args: + app: The Flask application instance. + + Returns: + True on success, False if the secrets file is missing (auth unavailable). + """ + # In read_only or open mode, skip OAuth initialization + mode = get_access_mode() + if mode != ACCESS_MODE_AUTHENTICATED: + logger.warning("Access mode '%s': skipping OAuth initialisation.", mode) + return True + + try: + secrets = load_client_secrets() + except (FileNotFoundError, json.JSONDecodeError) as exc: + logger.warning("Could not load client secrets (%s). Auth unavailable.", exc) + return False + + # Trust X-Forwarded-Proto / X-Forwarded-Host headers injected by nginx + # so Flask builds correct https:// redirect URIs behind the ingress. + # Only apply ProxyFix once to avoid nested wrapping. + if not isinstance(app.wsgi_app, ProxyFix): + app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1) + + oauth.init_app(app) + + # Build scope string from: standard OIDC defaults (VB_OIDC_DEFAULT_SCOPES) + + # VegBank-specific scopes (set by Helm values). Deduplicate while preserving order. + base_scopes = DEFAULT_SCOPES.split() + vb_scopes = [SCOPE_ADMIN, SCOPE_CONTRIBUTOR, SCOPE_USER] + scope_request = " ".join(dict.fromkeys(base_scopes + vb_scopes)) + + oauth.register( + name="vegbank_oidc", + client_id=secrets.get("client_id"), + client_secret=secrets.get("client_secret"), + server_metadata_url=secrets.get("server_metadata_url"), + client_kwargs={"scope": scope_request}, + ) + + logger.info("OAuth client initialised.") + return True + + +@functools.lru_cache(maxsize=1) +def get_jwks_keys(): + """Fetch and cache the JWKS signing keys from the OIDC provider. + + These keys are used to validate JWT token signatures. Care must be taken to fetch + them only from trustworthy sources (via the OIDC provider's metadata endpoint over + HTTPS). The keys may change periodically, so the cache will be invalidated and keys + will be refetched on the next call after the application is restarted. + + Returns: + authlib.jose.JsonWebKey: A ``JsonWebKeySet`` ready for ``jwt.decode``. + + Raises: + ValueError: If the OIDC server metadata does not expose a ``jwks_uri``. + requests.RequestException: If errors while fetching the JWKS. + """ + metadata = oauth.vegbank_oidc.load_server_metadata() + jwks_uri = metadata.get("jwks_uri") + if not jwks_uri: + raise ValueError("OIDC provider metadata does not contain 'jwks_uri'") + + response = _requests.get(jwks_uri, timeout=10) + response.raise_for_status() + return JsonWebKey.import_key_set(response.json()) + + +def _extract_bearer_token(): + """Extract the raw JWT string from the + ``Authorization: Bearer …`` header. + + Returns: + str | None: The token string, or ``None`` if the header is absent / malformed. + """ + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + token = auth_header[7:] + + # caps the token length to prevent huge tokens from causing DoS issues in downstream processing. + if len(token) > MAX_TOKEN_LEN: + return None # triggers 401 + return token + return None + + +def _decode_and_validate_token(token_str: str): + """Decode *and* full-validate a JWT against the OIDC provider's JWKS. + + Validates signature, issuer (``iss``), audience (``aud``), and authorized-party (``azp``) claims. + + Args: + token_str: Raw JWT string + + Returns: + The validated claims object. + + Raises: + DecodeError: Token could not be decoded. + InvalidTokenError: Signature is valid but one or more claims are + invalid (such as expired tokens). + BadSignatureError: JWKS signature verification failed. + ValueError: ``jwks_uri`` missing from OIDC metadata. + requests.RequestException: Network / HTTP error fetching JWKS. + """ + jwks = get_jwks_keys() + metadata = oauth.vegbank_oidc.load_server_metadata() + issuer = metadata.get("issuer") + + client_id = load_client_secrets().get("client_id") + + claims = jwt.decode( + token_str, + jwks, + claims_options={ + "iss": {"essential": True, "value": issuer}, + "aud": {"essential": True, "value": client_id}, + "azp": {"essential": True, "value": client_id}, + }, + ) + claims.validate() + return claims + + +def _auth_error_response(message, status, details=None): + """Generate a uniform JSON error response for authentication/authorization errors. + + All auth-related error responses should use this helper to guarantee a consistent ``{"error": {"message": ..., "details": ...}}`` object. + + Args: + message: Error description. + status: HTTP status code. + details: Optional additional context (``str(exc)``). Omitted from the response when *None*. + + Returns: + Tuple of (JSON response, status code). + """ + error = {"message": message} + if details is not None: + error["details"] = details + return jsonify({"error": error}), status + + +def _token_error_response(exc): + """Produce a uniform JSON error response for token validation/exchange failures.""" + error_map = { + DecodeError: ("Token decoding failed", 401), + InvalidClientError: ("OIDC client authentication failed", 401), + InvalidTokenError: ("Token validation failed", 401), + InvalidGrantError: ("Invalid or expired refresh token", 401), + BadSignatureError: ("Token signature verification failed", 401), + OAuthError: ("Authorization failed", 401), + OAuth2Error: ("An OAuth2 error occurred", 401), + KeyError: ("Invalid token structure", 401), + TypeError: ("Invalid token structure", 401), + MissingParameterError: ("Missing required parameter", 400), + ValueError: ("OIDC provider configuration error", 500), + _requests.RequestException: ("Failed to fetch OIDC provider keys", 502), + } + for exc_types, (message, status) in error_map.items(): + if isinstance(exc, exc_types): + return _auth_error_response(message, status, details=str(exc)) + # Unexpected exception — treat as server error + return _auth_error_response("Internal authentication error", 500, details=str(exc)) + + +def _token_response(token: dict, message: str = "Token exchange successful"): + """Produce a uniform JSON response with access and refresh tokens. + + Args: + token: Dict containing token data with 'access_token' and 'refresh_token' keys. + message: Optional message to include in response. + + Returns: + Tuple of (JSON response, 200 status code). + """ + return ( + jsonify( + { + "message": message, + "token": { + "access_token": token.get("access_token"), + "refresh_token": token.get("refresh_token"), + }, + } + ), + 200, + ) + + +_ORCID_HTTPS_PREFIX = "https://orcid.org/" +_ORCID_HTTP_PREFIX = "http://orcid.org/" + + +def extract_orcid(claims: dict | None) -> str | None: + """Extract a normalised ORCID iD URI from JWT claims. + + Reads the ``orcid`` claim. The returned value is always the canonical + HTTPS URI form (``https://orcid.org/XXXX-XXXX-XXXX-XXXX``). + + Args: + claims: Decoded JWT claims dict, or ``None``. + + Returns: + Canonical ORCID URI (e.g. ``"https://orcid.org/0000-0002-1825-0097"``), + or ``None`` if the ``orcid`` claim is absent or malformed. + """ + if not claims: + return None + + raw = claims.get("orcid") + + if not raw or not isinstance(raw, str): + return None + + # Strip http(s)://orcid.org/ prefix, leaving just the bare ID + if raw.startswith(_ORCID_HTTPS_PREFIX): + bare = raw[len(_ORCID_HTTPS_PREFIX):] + elif raw.startswith(_ORCID_HTTP_PREFIX): + bare = raw[len(_ORCID_HTTP_PREFIX):] + else: + bare = raw + + # Validate: XXXX-XXXX-XXXX-XXXX where the last character may be X (checksum digit) + if not re.fullmatch(r"\d{4}-\d{4}-\d{4}-\d{3}[0-9X]", bare): + return None + + return _ORCID_HTTPS_PREFIX + bare + + +def _store_user_context(claims): + """Store decoded token claims in request context.""" + g.token_claims = claims + + +def _validate_and_extract_claims(required_scope=None): + """Validate bearer token and optionally check required scope. + + Args: + required_scope: Optional scope string to validate. + + Returns: + Tuple of (claims_dict, error_response_tuple) where error_response_tuple is None on success. + """ + token_str = _extract_bearer_token() + if not token_str: + return None, _auth_error_response("Missing or invalid Authorization header", 401) + + try: + claims = _decode_and_validate_token(token_str) + except (DecodeError, InvalidTokenError, BadSignatureError, ValueError, RequestException) as exc: + return None, _token_error_response(exc) + + # Scope check if required + if required_scope: + token_scopes = claims.get("scope", "").split() + if required_scope not in token_scopes: + return None, _auth_error_response( + f"Insufficient scope. Required: {required_scope}", + 403, + details=f"Available scopes: {' '.join(token_scopes)}", + ) + + return claims, None + -def _echo_inputs(value: int) -> int: - """Echo arguments for testing + +def require_token(methods=None): + """Decorator - protect an endpoint that requires *any* valid JWT. + + **Only enforces authentication when accessMode='authenticated'.** + In 'read_only' and 'open' modes, this decorator allows all requests. + + Returns ``401`` if the token is missing, expired, or otherwise invalid. + + Can enforce auth on specific HTTP methods only. If ``methods`` is None, + protects all methods. Args: - value: Integer input to be tested + methods: Optional list of HTTP method names (e.g., ``['POST', 'PUT', 'DELETE']``) to protect. + If None, all methods are protected. + If the current request method is not in the list, auth is skipped. + + Example: + ``@require_token(methods=['POST', 'PUT', 'DELETE'])`` - only protect write operations + """ + def decorator(f): + @functools.wraps(f) + def decorated(*args, **kwargs): + mode = get_access_mode() + + # In read_only or open mode, skip auth entirely + if mode != ACCESS_MODE_AUTHENTICATED: + logger.warning("Access mode '%s': skipping token validation", mode) + return f(None, *args, **kwargs) + + # If methods are specified, only enforce auth for those methods + if methods is not None and request.method not in methods: + # No auth required for this method; pass None as claims + return f(None, *args, **kwargs) + + claims, error = _validate_and_extract_claims() + if error: + return error + + _store_user_context(claims) + return f(claims, *args, **kwargs) + + return decorated + + return decorator + + +def require_scope(required_scope: str, methods=None): + """Decorator factory - protect an endpoint that requires a specific scope. + + **Only enforces authorization when accessMode='authenticated'.** + In 'read_only' and 'open' modes, this decorator allows all requests. + + Supported VegBank scopes: + + * ``vegbank:admin`` - admin ops + * ``vegbank:contributor`` - create/update access for vegbank data + * ``vegbank:user`` - create/update access for user datasets + + Returns ``401`` for missing / invalid tokens, ``403`` if the required scope + is absent from the token. + + Can enforce auth on specific HTTP methods only. If ``methods`` is None, + protects all methods. + + **Claims Parameter Injection:** + + This decorator injects a ``claims`` keyword argument into wrapped functions. + The ``claims`` dict contains user info (e.g., preferred_username, email, scopes) + extracted from the JWT token. Claims are only populated in 'authenticated' mode; + in other modes, claims is None. Route handlers that need audit logging should + accept a ``claims=None`` parameter and check it before use. + + Args: + required_scope: Valid OAuth 2.0 scope string that must be present in the token's ``scope`` claim. + methods: Optional list of HTTP method names (e.g., ``['POST', 'PUT', 'DELETE']``) to protect. + If None, all methods are protected. + If the current request method is not in the list, auth is skipped. + + Example: + ``@require_scope(SCOPE_CONTRIBUTOR, methods=['POST'])`` - only protect POST operations + + Handler Example: + ``def my_handler(vb_code, claims=None):`` - claims are injected as kwargs + """ + def decorator(f): + @functools.wraps(f) + def decorated(*args, **kwargs): + mode = get_access_mode() + + # In read_only or open mode, skip auth entirely + if mode != ACCESS_MODE_AUTHENTICATED: + logger.warning("Access mode '%s': skipping scope validation", mode) + # Store None in g for consistency + g.token_claims = None + return f(*args, **kwargs) + + # If methods are specified, only enforce auth for those methods + if methods is not None and request.method not in methods: + # No auth required for this method; store None as claims + g.token_claims = None + return f(*args, **kwargs) + + claims, error = _validate_and_extract_claims(required_scope=required_scope) + if error: + return error + + _store_user_context(claims) + # Pass claims as keyword argument for explicit access in handlers + kwargs['claims'] = claims + return f(*args, **kwargs) + + return decorated + + return decorator + + +@auth_bp.route("/login", methods=["GET"]) +def login(): + """Initiate the OIDC login flow. + + Sends the user to the provider's login page. After successful + authentication the provider redirects back to the ``/authorize`` + callback. + + Args: + (None) + + Returns: + 302 redirect to the provider's authorization endpoint. + 401/500 JSON error response if login fails. + 403 JSON response if authentication is disabled for the current access mode. + + """ + mode = get_access_mode() + if mode != ACCESS_MODE_AUTHENTICATED: + return _auth_error_response(f"Authentication is disabled in '{mode}' mode.", 403) + + try: + return oauth.vegbank_oidc.authorize_redirect(url_for("main.auth.authorize", _external=True)) + except (OAuthError, RequestException) as exc: + logger.warning("OIDC authorize_redirect error: %s", exc) + return _token_error_response(exc) + + +@auth_bp.route("/authorize", methods=["GET"]) +def authorize(): + """OIDC authorization callback endpoint. + + Keycloak redirects here after a successful login with a short-lived + authorization code. This endpoint exchanges that code for an access + token, stores the token and returns it to the caller. + + Returns: + 200 JSON with ``token`` on success. + 401 JSON with error details on failure. + 403 JSON response if authentication is disabled for the current access mode. + """ + mode = get_access_mode() + if mode != ACCESS_MODE_AUTHENTICATED: + return _auth_error_response(f"Authentication is disabled in '{mode}' mode.", 403) + + try: + token = oauth.vegbank_oidc.authorize_access_token() + except (OAuthError, RequestException) as exc: + logger.debug("OIDC token exchange error: %s", exc) + return _token_error_response(exc) + + return _token_response(token, message="Authorization successful") + + +@auth_bp.route("/refresh", methods=["POST"]) +def refresh_token(): + """Re-validate the user session and return a new access token using the refresh token. + + When an access token expires, the client can call this endpoint with the refresh token + to obtain a new access token without requiring the user to log in again. The client + can also pass the desired scopes for the new access token, which must be a subset + of the original scopes granted to the refresh token. + + Parameters (in JSON body): + - ``refresh_token`` (string, required): The refresh token issued by the OIDC provider. + - ``scope`` (string, optional): Space-separated list of scopes to request for the new access token. If omitted, the new access token will have the same scopes as the original token. + + Returns: + 200 JSON with new ``access_token`` and ``refresh_token`` on success. + 400 JSON if the request is missing required parameters. + 401 JSON if the refresh token is invalid, expired, or if client authentication fails. + 500 JSON for unexpected server errors. + """ + # Get the refresh token and desired scopes from the JSON body + data = request.get_json(silent=True) + if not data: + return _token_error_response(MissingParameterError("refresh_token")) + + user_refresh_token = data.get("refresh_token") + if not user_refresh_token: + return _token_error_response(MissingParameterError("refresh_token")) + + # The client should pass the scopes that it would like to request for the + # new access token. If no scopes are provided, we will attempt to get a + # new access token with the same scopes as the original token. The + # requested scopes must match or be a subset of the original scopes granted + # to the token, otherwise the OIDC provider will reject the request. + requested_scope = data.get("scope") + + # Use Authlib to exchange the refresh token for a new access token + try: + if not requested_scope: + # If no scope is provided, omit the scope parameter to get the same scopes as the original token + new_tokens = oauth.vegbank_oidc.fetch_access_token( + grant_type="refresh_token", + refresh_token=user_refresh_token, + ) + else: + new_tokens = oauth.vegbank_oidc.fetch_access_token( + grant_type="refresh_token", + refresh_token=user_refresh_token, + scope=requested_scope, + ) + return _token_response(new_tokens, message="Authorization successful") + except InvalidGrantError as exc: + # The refresh token was invalid, expired, or revoked by the provider + logger.debug("The refresh token is invalid or expired: %s", exc) + return _token_error_response(exc) + except InvalidClientError as exc: + # The client_id or client_secret is wrong + logger.warning("OIDC client authentication failed: %s", exc) + return _token_error_response(exc) + except OAuth2Error as exc: + logger.debug("An OAuth2 error occurred: %s", exc) + return _token_error_response(exc) + except Exception as exc: + # A safety net for non-OAuth errors (e.g., network issues) + logger.error("Unexpected Exception during refresh: %s", exc, exc_info=True) + return _token_error_response(exc) + +def get_access_mode() -> str: + """Get the current access mode from environment. + Returns: - Integer value of the argument passed in + str: One of 'read_only', 'open', or 'authenticated'. Defaults to 'authenticated'. """ - logger.debug("Received input value: %s", value) - return value + mode = os.getenv("VB_ACCESS_MODE", ACCESS_MODE_AUTHENTICATED).lower() + if mode not in (ACCESS_MODE_READ_ONLY, ACCESS_MODE_OPEN, ACCESS_MODE_AUTHENTICATED): + logger.warning(f"Invalid access mode '{mode}', falling back to '{ACCESS_MODE_AUTHENTICATED}'") + return ACCESS_MODE_AUTHENTICATED + return mode \ No newline at end of file diff --git a/tests/test_auth.py b/tests/test_auth.py index 660b364..556180f 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,19 +1,31 @@ -"""Tests for the authentication module.""" +"""Unit tests for auth.py helpers.""" -from dataone.auth import _echo_inputs +from dataone.auth import extract_orcid -class TestEchoInputs: - """Test suite for _echo_inputs function.""" +def test_extract_orcid_returns_https_uri_from_https_orcid_claim(): + """Test that extract_orcid returns the canonical HTTPS URI when the orcid claim is already a full HTTPS URI.""" + claims = {"orcid": "https://orcid.org/0000-0002-1825-0097"} + assert extract_orcid(claims) == "https://orcid.org/0000-0002-1825-0097" - def test_echo_inputs_returns_same_value(self) -> None: - """Test that _echo_inputs returns the input value unchanged.""" - assert _echo_inputs(42) == 42 - def test_echo_inputs_zero(self) -> None: - """Test _echo_inputs with zero.""" - assert _echo_inputs(0) == 0 +def test_extract_orcid_normalises_http_orcid_claim_to_https(): + """Test that extract_orcid upgrades an http:// orcid claim URI to the canonical https:// URI.""" + claims = {"orcid": "http://orcid.org/0000-0002-1825-0097"} + assert extract_orcid(claims) == "https://orcid.org/0000-0002-1825-0097" - def test_echo_inputs_negative(self) -> None: - """Test _echo_inputs with negative integer.""" - assert _echo_inputs(-5) == -5 + +def test_extract_orcid_normalises_bare_id_to_https_uri(): + """Test that extract_orcid expands a bare ORCID iD to the canonical HTTPS URI.""" + claims = {"orcid": "0000-0002-1825-0097"} + assert extract_orcid(claims) == "https://orcid.org/0000-0002-1825-0097" + + +def test_extract_orcid_returns_none_for_none_input(): + """Test that extract_orcid returns None when called with None instead of a claims dict.""" + assert extract_orcid(None) is None + + +def test_extract_orcid_returns_none_for_empty_claims(): + """Test that extract_orcid returns None when called with an empty claims dict.""" + assert extract_orcid({}) is None diff --git a/uv.lock b/uv.lock index 2e16e25..5a6eab2 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,151 @@ version = 1 revision = 3 requires-python = ">=3.13" +[[package]] +name = "authlib" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "joserfc" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[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/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 = "click" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -11,10 +156,69 @@ 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 = "cryptography" +version = "48.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, + { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, + { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, + { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, + { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, + { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, + { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, + { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, +] + [[package]] name = "dataone-auth" version = "0.1.0" source = { editable = "." } +dependencies = [ + { name = "authlib" }, + { name = "flask" }, + { name = "requests" }, + { name = "werkzeug" }, +] [package.dev-dependencies] dev = [ @@ -23,6 +227,12 @@ dev = [ ] [package.metadata] +requires-dist = [ + { name = "authlib", specifier = ">=1.7.2" }, + { name = "flask", specifier = ">=3.1.3" }, + { name = "requests", specifier = ">=2.33.1" }, + { name = "werkzeug", specifier = ">=3.1.8" }, +] [package.metadata.requires-dev] dev = [ @@ -30,6 +240,32 @@ dev = [ { name = "ruff", specifier = ">=0.15.12" }, ] +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "idna" +version = "3.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -39,6 +275,91 @@ 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 = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "joserfc" +version = "1.6.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/dc/5f768c2e391e9afabe5d18e3221346deb5fb6338565f1ccc9e7c6d7befdd/joserfc-1.6.5.tar.gz", hash = "sha256:1482a7db78fb4602e44ed89e51b599d052e091288c7c532c5b694e20149dec48", size = 231881, upload-time = "2026-05-06T04:58:13.408Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/3b/ad1cb22e75c963b1f07c8a2329bf47227ce7e4361df5eb2fb101b2ce33ef/joserfc-1.6.5-py3-none-any.whl", hash = "sha256:e9878a0f8243fe7b95e11fdda81374ca9f7a689e302751579d3dfdeec559675e", size = 70464, upload-time = "2026-05-06T04:58:11.668Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -57,6 +378,15 @@ 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 = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -82,6 +412,21 @@ 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 = "ruff" version = "0.15.12" @@ -106,3 +451,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, ] + +[[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" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +] From 4b73af63124680cfc313311559a1aab8e28fdecd Mon Sep 17 00:00:00 2001 From: Rushiraj Nenuji Date: Thu, 7 May 2026 11:03:00 -0700 Subject: [PATCH 02/63] Add initial sequence diagram Add initial sequence diagram --- README.md | 2 +- docs/README.md | 3 +++ docs/diagrams/auth-sequence.md | 45 ++++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 docs/README.md create mode 100644 docs/diagrams/auth-sequence.md diff --git a/README.md b/README.md index 493e680..e00991d 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ DataONE creates open source, community projects. We [welcome contributions](./C ## Documentation -Documentation is a work in progress, and can be found ... +Documentation is a work in progress, and can be found in [docs](./docs). ## Development build diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..96edfb4 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,3 @@ +# Docs + +- [Auth Sequence Diagram](./diagrams/auth-sequence.md) diff --git a/docs/diagrams/auth-sequence.md b/docs/diagrams/auth-sequence.md new file mode 100644 index 0000000..a512d73 --- /dev/null +++ b/docs/diagrams/auth-sequence.md @@ -0,0 +1,45 @@ +### DataONE Auth + +```mermaid +sequenceDiagram + autonumber + participant User as User (Browser) + participant API as DataONE Auth Client + participant KC as Keycloak Server + + Note over User, KC: [1] The Login Initiation + User->>API: GET /login + API-->>User: 302 Redirect to Keycloak (with client_id & redirect_uri) + + User->>KC: Access Keycloak Login Page + User->>KC: Submit Credentials + KC->>KC: Authenticate User + KC-->>User: 302 Redirect to VB API /authorize?code=XYZ + + Note over API, KC: [2] The Backchannel Exchange + User->>API: GET /authorize?code=XYZ + activate API + API->>KC: POST /token (code=XYZ, client_id, client_secret) + KC->>KC: Validate Code & Secret + KC-->>API: Returns: Access Token + Refresh Token + API-->>User: Returns Tokens + deactivate API + + Note over User, API: [3] Standard Operation + User->>API: GET /data (Header: Authorization: Bearer ) + API->>API: Local Validation of AT + API-->>User: 200 OK (Data) + + Note over User, KC: [4] The Refresh Flow + User->>API: POST /refresh (Body: refresh_token) + activate API + API->>KC: POST /token (grant_type=refresh_token, client_secret) + alt RT is Valid + KC-->>API: New Access Token + New Refresh Token + API-->>User: 200 OK (New Tokens) + else RT is Invalid/Expired + KC-->>API: 400 Bad Request (Invalid Grant) + API-->>User: 302 Redirect to /login + end + deactivate API +``` From 7d9c6d32e52b5b5dfd85c3e2f97bfeb16b093772 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Thu, 7 May 2026 16:09:55 -0700 Subject: [PATCH 03/63] add basic factory and adapter structure --- pyproject.toml | 13 ++ src/dataone/adapters/base.py | 24 ++++ src/dataone/adapters/fastapi.py | 11 ++ src/dataone/adapters/flask.py | 11 ++ src/dataone/factory.py | 19 +++ tests/test_factory.py | 41 +++++++ uv.lock | 206 ++++++++++++++++++++++++++++++++ 7 files changed, 325 insertions(+) create mode 100644 src/dataone/adapters/base.py create mode 100644 src/dataone/adapters/fastapi.py create mode 100644 src/dataone/adapters/flask.py create mode 100644 src/dataone/factory.py create mode 100644 tests/test_factory.py diff --git a/pyproject.toml b/pyproject.toml index 0637e9a..484696e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,19 @@ dependencies = [ [project.scripts] dataone = "dataone:main" +[project.optional-dependencies] +flask = [ + "flask>=3.1.3", +] +fastapi = [ + "fastapi>=0.136.1", + "httpx>=0.28.1", + "starlette>=1.0.0", +] +starlette = [ + "httpx>=0.28.1", +] + [tool.hatch.build.targets.wheel] packages = ["src/dataone"] diff --git a/src/dataone/adapters/base.py b/src/dataone/adapters/base.py new file mode 100644 index 0000000..6d8e50c --- /dev/null +++ b/src/dataone/adapters/base.py @@ -0,0 +1,24 @@ +class BaseAuthAdapter: + def __init__(self, config: dict): + self.config = config + self.oauth = self._initialize_oauth() + self._setup_providers() + + def _initialize_oauth(self): + raise NotImplementedError + + def _setup_providers(self): + self.register( + name="vegbank_oidc", + #client_id=secrets.get("client_id"), + #client_secret=secrets.get("client_secret"), + #server_metadata_url=secrets.get("server_metadata_url"), + #client_kwargs={"scope": scope_request}, + ) + + def __getattr__(self, name): + """ + Delegate all unknown attribute/method lookups to the underlying Authlib OAuth object. + This automatically exposes .register(), .init_app(), etc. + """ + return getattr(self.oauth, name) \ No newline at end of file diff --git a/src/dataone/adapters/fastapi.py b/src/dataone/adapters/fastapi.py new file mode 100644 index 0000000..f137db7 --- /dev/null +++ b/src/dataone/adapters/fastapi.py @@ -0,0 +1,11 @@ +from .base import BaseAuthAdapter + +class FastAPIAuthAdapter(BaseAuthAdapter): + def _initialize_oauth(self): + from authlib.integrations.starlette_client import OAuth + return OAuth() + + async def login(self, name: str, request, **kwargs): + client = self.oauth.create_client(name) + # FastAPI/Starlette is async and requires the request object + return await client.authorize_redirect(request, **kwargs) \ No newline at end of file diff --git a/src/dataone/adapters/flask.py b/src/dataone/adapters/flask.py new file mode 100644 index 0000000..a0dd4ec --- /dev/null +++ b/src/dataone/adapters/flask.py @@ -0,0 +1,11 @@ +from .base import BaseAuthAdapter + +class FlaskAuthAdapter(BaseAuthAdapter): + def _initialize_oauth(self): + from authlib.integrations.flask_client import OAuth + return OAuth() + + def login(self, name: str, **kwargs): + client = self.oauth.create_client(name) + # Standard Flask is synchronous, no request object needed + return client.authorize_redirect(**kwargs) \ No newline at end of file diff --git a/src/dataone/factory.py b/src/dataone/factory.py new file mode 100644 index 0000000..1002591 --- /dev/null +++ b/src/dataone/factory.py @@ -0,0 +1,19 @@ +class AuthFactory: + + _registry = { + "flask": "dataone.adapters.flask.FlaskAuthAdapter", + "fastapi": "dataone.adapters.fastapi.FastAPIAuthAdapter", + "starlette": "dataone.adapters.fastapi.FastAPIAuthAdapter", + } + + @classmethod + def create_client(cls, framework: str, config: dict): + import_path = cls._registry.get(framework.lower()) + if not import_path: + raise ValueError(f"Unsupported framework: {framework}") + + module_path, class_name = import_path.rsplit(".", 1) + module = __import__(module_path, fromlist=[class_name]) + AdapterClass = getattr(module, class_name) + + return AdapterClass(config=config) \ No newline at end of file diff --git a/tests/test_factory.py b/tests/test_factory.py new file mode 100644 index 0000000..9bde9e8 --- /dev/null +++ b/tests/test_factory.py @@ -0,0 +1,41 @@ +# tests/test_factory.py +import pytest +from dataone.factory import AuthFactory + +# Mock config to pass into our adapters +MOCK_CONFIG = { + "GOOGLE_ID": "mock_id", + "GOOGLE_SECRET": "mock_secret" +} + +def test_factory_returns_flask_adapter(): + # Skip test if Flask isn't installed in this environment + pytest.importorskip("flask") + + # Import inside the test to avoid top-level crashes + from dataone.adapters.flask import FlaskAuthAdapter + + # Act + adapter = AuthFactory.create_client("flask", config=MOCK_CONFIG) + + # Assert + assert isinstance(adapter, FlaskAuthAdapter) + assert adapter.config == MOCK_CONFIG + +def test_factory_returns_fastapi_adapter(): + # Skip test if Starlette/FastAPI aren't installed in this environment + pytest.importorskip("starlette") + + from dataone.adapters.fastapi import FastAPIAuthAdapter + + # Act + adapter = AuthFactory.create_client("fastapi", config=MOCK_CONFIG) + + # Assert + assert isinstance(adapter, FastAPIAuthAdapter) + assert adapter.config == MOCK_CONFIG + +def test_factory_raises_error_on_unknown_framework(): + # Act & Assert + with pytest.raises(ValueError, match="Unsupported framework"): + AuthFactory.create_client("django", config=MOCK_CONFIG) \ No newline at end of file diff --git a/uv.lock b/uv.lock index 5a6eab2..5b49c7f 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,36 @@ version = 1 revision = 3 requires-python = ">=3.13" +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + [[package]] name = "authlib" version = "1.7.2" @@ -220,6 +250,19 @@ dependencies = [ { name = "werkzeug" }, ] +[package.optional-dependencies] +fastapi = [ + { name = "fastapi" }, + { name = "httpx" }, + { name = "starlette" }, +] +flask = [ + { name = "flask" }, +] +starlette = [ + { name = "httpx" }, +] + [package.dev-dependencies] dev = [ { name = "pytest" }, @@ -229,10 +272,16 @@ dev = [ [package.metadata] requires-dist = [ { name = "authlib", specifier = ">=1.7.2" }, + { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.136.1" }, { name = "flask", specifier = ">=3.1.3" }, + { name = "flask", marker = "extra == 'flask'", specifier = ">=3.1.3" }, + { name = "httpx", marker = "extra == 'fastapi'", specifier = ">=0.28.1" }, + { name = "httpx", marker = "extra == 'starlette'", specifier = ">=0.28.1" }, { name = "requests", specifier = ">=2.33.1" }, + { name = "starlette", marker = "extra == 'fastapi'", specifier = ">=1.0.0" }, { name = "werkzeug", specifier = ">=3.1.8" }, ] +provides-extras = ["flask", "fastapi", "starlette"] [package.metadata.requires-dev] dev = [ @@ -240,6 +289,22 @@ dev = [ { name = "ruff", specifier = ">=0.15.12" }, ] +[[package]] +name = "fastapi" +version = "0.136.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, +] + [[package]] name = "flask" version = "3.1.3" @@ -257,6 +322,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "idna" version = "3.13" @@ -387,6 +489,77 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -452,6 +625,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, ] +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + +[[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" From bfbbe9d90d5a8cf3a0897934e7ceb2261f405aa3 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 8 May 2026 10:01:50 -0700 Subject: [PATCH 04/63] rework how client is configured --- src/dataone/adapters/base.py | 27 +++++++++++++++++++-------- src/dataone/factory.py | 4 ++-- tests/test_factory.py | 19 +++++++++++-------- 3 files changed, 32 insertions(+), 18 deletions(-) diff --git a/src/dataone/adapters/base.py b/src/dataone/adapters/base.py index 6d8e50c..f1567f1 100644 --- a/src/dataone/adapters/base.py +++ b/src/dataone/adapters/base.py @@ -1,6 +1,13 @@ +import os + class BaseAuthAdapter: - def __init__(self, config: dict): - self.config = config + + DEFAULT_PROVIDER_NAME = "vegbank_oidc" + DEFAULT_SCOPES = "openid email profile" + + def __init__(self, secrets, scopes): + self.secrets = secrets + self.scopes = scopes self.oauth = self._initialize_oauth() self._setup_providers() @@ -8,12 +15,16 @@ def _initialize_oauth(self): raise NotImplementedError def _setup_providers(self): - self.register( - name="vegbank_oidc", - #client_id=secrets.get("client_id"), - #client_secret=secrets.get("client_secret"), - #server_metadata_url=secrets.get("server_metadata_url"), - #client_kwargs={"scope": scope_request}, + + base_scopes = self.DEFAULT_SCOPES.split() + scope_request = " ".join(dict.fromkeys(base_scopes + self.scopes)) + + self.oauth.register( + name=self.DEFAULT_PROVIDER_NAME, + client_id=self.secrets.get("client_id"), + client_secret=self.secrets.get("client_secret"), + server_metadata_url=self.secrets.get("server_metadata_url"), + client_kwargs={"scope": scope_request}, ) def __getattr__(self, name): diff --git a/src/dataone/factory.py b/src/dataone/factory.py index 1002591..13fd65c 100644 --- a/src/dataone/factory.py +++ b/src/dataone/factory.py @@ -7,7 +7,7 @@ class AuthFactory: } @classmethod - def create_client(cls, framework: str, config: dict): + def create_client(cls, framework: str, secrets: dict, scopes: list): import_path = cls._registry.get(framework.lower()) if not import_path: raise ValueError(f"Unsupported framework: {framework}") @@ -16,4 +16,4 @@ def create_client(cls, framework: str, config: dict): module = __import__(module_path, fromlist=[class_name]) AdapterClass = getattr(module, class_name) - return AdapterClass(config=config) \ No newline at end of file + return AdapterClass(secrets=secrets, scopes=scopes) \ No newline at end of file diff --git a/tests/test_factory.py b/tests/test_factory.py index 9bde9e8..f72d26c 100644 --- a/tests/test_factory.py +++ b/tests/test_factory.py @@ -3,11 +3,14 @@ from dataone.factory import AuthFactory # Mock config to pass into our adapters -MOCK_CONFIG = { - "GOOGLE_ID": "mock_id", - "GOOGLE_SECRET": "mock_secret" +MOCK_SECRETS = { + "client_id": "test client", + "client_secret": "a string", + "server_metadata_url": "https://url.com", } +MOCK_SCOPES = ["vegbank:admin", "vegbank:contributor", "vegbank:user"] + def test_factory_returns_flask_adapter(): # Skip test if Flask isn't installed in this environment pytest.importorskip("flask") @@ -16,11 +19,11 @@ def test_factory_returns_flask_adapter(): from dataone.adapters.flask import FlaskAuthAdapter # Act - adapter = AuthFactory.create_client("flask", config=MOCK_CONFIG) + adapter = AuthFactory.create_client("flask", secrets=MOCK_SECRETS, scopes=MOCK_SCOPES) # Assert assert isinstance(adapter, FlaskAuthAdapter) - assert adapter.config == MOCK_CONFIG + assert adapter.secrets == MOCK_SECRETS def test_factory_returns_fastapi_adapter(): # Skip test if Starlette/FastAPI aren't installed in this environment @@ -29,13 +32,13 @@ def test_factory_returns_fastapi_adapter(): from dataone.adapters.fastapi import FastAPIAuthAdapter # Act - adapter = AuthFactory.create_client("fastapi", config=MOCK_CONFIG) + adapter = AuthFactory.create_client("fastapi", secrets=MOCK_SECRETS, scopes=MOCK_SCOPES) # Assert assert isinstance(adapter, FastAPIAuthAdapter) - assert adapter.config == MOCK_CONFIG + assert adapter.secrets == MOCK_SECRETS def test_factory_raises_error_on_unknown_framework(): # Act & Assert with pytest.raises(ValueError, match="Unsupported framework"): - AuthFactory.create_client("django", config=MOCK_CONFIG) \ No newline at end of file + AuthFactory.create_client("django", secrets=MOCK_SECRETS, scopes=MOCK_SCOPES) \ No newline at end of file From 5acfdc057b8f4388138db62165c5056009e7dc2b Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 8 May 2026 11:19:57 -0700 Subject: [PATCH 05/63] remove login methods --- src/dataone/adapters/fastapi.py | 7 +------ src/dataone/adapters/flask.py | 7 +------ 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/src/dataone/adapters/fastapi.py b/src/dataone/adapters/fastapi.py index f137db7..11e27c8 100644 --- a/src/dataone/adapters/fastapi.py +++ b/src/dataone/adapters/fastapi.py @@ -3,9 +3,4 @@ class FastAPIAuthAdapter(BaseAuthAdapter): def _initialize_oauth(self): from authlib.integrations.starlette_client import OAuth - return OAuth() - - async def login(self, name: str, request, **kwargs): - client = self.oauth.create_client(name) - # FastAPI/Starlette is async and requires the request object - return await client.authorize_redirect(request, **kwargs) \ No newline at end of file + return OAuth() \ No newline at end of file diff --git a/src/dataone/adapters/flask.py b/src/dataone/adapters/flask.py index a0dd4ec..897c5a9 100644 --- a/src/dataone/adapters/flask.py +++ b/src/dataone/adapters/flask.py @@ -3,9 +3,4 @@ class FlaskAuthAdapter(BaseAuthAdapter): def _initialize_oauth(self): from authlib.integrations.flask_client import OAuth - return OAuth() - - def login(self, name: str, **kwargs): - client = self.oauth.create_client(name) - # Standard Flask is synchronous, no request object needed - return client.authorize_redirect(**kwargs) \ No newline at end of file + return OAuth() \ No newline at end of file From ab8eb4490f8062315db80a637df6d8c57df09a61 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 8 May 2026 11:20:12 -0700 Subject: [PATCH 06/63] add all the token validation methods --- src/dataone/adapters/base.py | 93 +++++++++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/src/dataone/adapters/base.py b/src/dataone/adapters/base.py index f1567f1..1a9e0fd 100644 --- a/src/dataone/adapters/base.py +++ b/src/dataone/adapters/base.py @@ -1,4 +1,5 @@ -import os +import requests +from authlib.jose import jwt, JsonWebKey class BaseAuthAdapter: @@ -27,6 +28,96 @@ def _setup_providers(self): client_kwargs={"scope": scope_request}, ) + def get_jwks_keys(): + """Fetch and cache the JWKS signing keys from the OIDC provider. + + These keys are used to validate JWT token signatures. Care must be taken to fetch + them only from trustworthy sources (via the OIDC provider's metadata endpoint over + HTTPS). The keys may change periodically, so the cache will be invalidated and keys + will be refetched on the next call after the application is restarted. + + Returns: + authlib.jose.JsonWebKey: A ``JsonWebKeySet`` ready for ``jwt.decode``. + + Raises: + ValueError: If the OIDC server metadata does not expose a ``jwks_uri``. + requests.RequestException: If errors while fetching the JWKS. + """ + + if hasattr(self, '_cached_jwks'): + return self._cached_jwks + + provider = getattr(self.oauth, self.DEFAULT_PROVIDER_NAME) + metadata = provider.load_server_metadata() + + jwks_uri = metadata.get("jwks_uri") + if not jwks_uri: + raise ValueError("OIDC provider metadata missing 'jwks_uri'") + + jwks_uri = metadata.get("jwks_uri") + if not jwks_uri: + raise ValueError("OIDC provider metadata does not contain 'jwks_uri'") + + response = requests.get(jwks_uri, timeout=10) + response.raise_for_status() + self._cached_jwks = JsonWebKey.import_key_set(response.json()) + + return self._cached_jwks + + def decode_and_validate_token(self, token_str: str): + """Decode *and* full-validate a JWT against the OIDC provider's JWKS. + + Validates signature, issuer (iss), audience (aud), and authorized-party (azp) claims. + """ + + jwks = self.get_jwks_keys() + + provider = getattr(self.oauth, self.DEFAULT_PROVIDER_NAME) + metadata = provider.load_server_metadata() + issuer = metadata.get("issuer") + + client_id = self.secrets.get("client_id") + + claims = jwt.decode( + token_str, + jwks, + claims_options={ + "iss": {"essential": True, "value": issuer}, + "aud": {"essential": True, "value": client_id}, + "azp": {"essential": True, "value": client_id}, + }, + ) + claims.validate() + return claims + + def validate_and_extract_claims(self, token_str: str, required_scope: str = None): + """Validate a token string and optionally check required scope. + + Args: + token_str: The raw JWT string. + required_scope: Optional scope string to validate. + + Returns: + The validated claims dict. + + Raises: + Exception: JoseError from Authlib if token is invalid/expired. + InsufficientScopeError: If the token lacks the required scope. + """ + # 1. Do the crypto math (the method we wrote previously) + claims = self.decode_and_validate_token(token_str) + + # 2. Scope check if required + if required_scope: + token_scopes = claims.get("scope", "").split() + if required_scope not in token_scopes: + raise InsufficientScopeError( + f"Insufficient scope. Required: {required_scope}. " + f"Available: {' '.join(token_scopes)}" + ) + + return claims + def __getattr__(self, name): """ Delegate all unknown attribute/method lookups to the underlying Authlib OAuth object. From 53640381cceffd41aaa5d782988fb3f555b26695 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 8 May 2026 11:20:20 -0700 Subject: [PATCH 07/63] prune out all the code we moved --- src/dataone/auth.py | 542 +------------------------------------------- 1 file changed, 2 insertions(+), 540 deletions(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index ed51503..28228a0 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -1,77 +1,10 @@ -"""Authentication module for the VegBank API. - -Implements OIDC / OAuth 2.0 login via a configurable OIDC provider using authlib. - -Deployment Modes ----------------- -The API supports three access modes controlled by the ``VB_ACCESS_MODE`` environment variable: - -``read_only`` - Authentication disabled. All endpoints are public. File uploads disabled. - -``open`` - Authentication disabled. All endpoints are public. File uploads allowed. - -``authenticated`` - Full authentication and authorization enabled. Protected endpoints require valid JWT tokens with appropriate scopes. - -Decorator overview --------------------------------------------------- -``require_token`` - Protects an endpoint that requires *any* valid, unexpired JWT issued by - the configured OIDC provider. - -``require_scope(scope)`` - Same as ``require_token`` but additionally asserts that the token contains the - correct Vegbank scope (e.g. ``"vegbank:admin"``, ``"vegbank:contributor"``, - ``"vegbank:user"``). - -""" - -import functools -import json -import logging import os import re -from requests import RequestException - -import requests as _requests -from authlib.integrations.base_client.errors import OAuthError -from authlib.integrations.flask_client import OAuth -from authlib.jose import JsonWebKey, jwt -from authlib.jose.errors import BadSignatureError, DecodeError, InvalidTokenError -from authlib.oauth2 import OAuth2Error -from authlib.oauth2.rfc6749.errors import InvalidGrantError, InvalidClientError - -from flask import Blueprint, g, jsonify, request, url_for -from werkzeug.middleware.proxy_fix import ProxyFix - -_DEFAULT_SECRETS_PATH = "/etc/vegbank/oidc/client_secrets.json" -MAX_TOKEN_LEN = 16_384 # Token length limit in characters (~16 KB) to prevent DoS attacks class MissingParameterError(Exception): """Raised when a required request parameter is missing.""" -# Standard OIDC scopes — overridable via environment variable -DEFAULT_SCOPES = os.getenv("VB_OIDC_DEFAULT_SCOPES", "openid email profile") - -# VegBank-specific scopes — configurable via environment variables set by Helm -SCOPE_ADMIN = os.getenv("VB_SCOPE_ADMIN", "vegbank:admin") -SCOPE_CONTRIBUTOR = os.getenv("VB_SCOPE_CONTRIBUTOR", "vegbank:contributor") -SCOPE_USER = os.getenv("VB_SCOPE_USER", "vegbank:user") - -# Deployment modes -ACCESS_MODE_READ_ONLY = "read_only" # Read-only mode: no uploads, no auth -ACCESS_MODE_OPEN = "open" # Open mode: uploads allowed, no auth -ACCESS_MODE_AUTHENTICATED = "authenticated" # Authenticated mode: auth required, full access control - -# Initialize module-level logger -logger = logging.getLogger(__name__) - -oauth = OAuth() -auth_bp = Blueprint("auth", __name__) - def load_client_secrets(filepath: str | None = None) -> dict: """Load client secrets from a JSON file. @@ -93,207 +26,10 @@ def load_client_secrets(filepath: str | None = None) -> dict: return json.load(f) -def init_oauth(app) -> bool: - """Initialise the OAuth client and register the OIDC provider. - - Call once at app startup, after creating the Flask instance. - - Args: - app: The Flask application instance. - - Returns: - True on success, False if the secrets file is missing (auth unavailable). - """ - # In read_only or open mode, skip OAuth initialization - mode = get_access_mode() - if mode != ACCESS_MODE_AUTHENTICATED: - logger.warning("Access mode '%s': skipping OAuth initialisation.", mode) - return True - - try: - secrets = load_client_secrets() - except (FileNotFoundError, json.JSONDecodeError) as exc: - logger.warning("Could not load client secrets (%s). Auth unavailable.", exc) - return False - - # Trust X-Forwarded-Proto / X-Forwarded-Host headers injected by nginx - # so Flask builds correct https:// redirect URIs behind the ingress. - # Only apply ProxyFix once to avoid nested wrapping. - if not isinstance(app.wsgi_app, ProxyFix): - app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1) - - oauth.init_app(app) - - # Build scope string from: standard OIDC defaults (VB_OIDC_DEFAULT_SCOPES) + - # VegBank-specific scopes (set by Helm values). Deduplicate while preserving order. - base_scopes = DEFAULT_SCOPES.split() - vb_scopes = [SCOPE_ADMIN, SCOPE_CONTRIBUTOR, SCOPE_USER] - scope_request = " ".join(dict.fromkeys(base_scopes + vb_scopes)) - - oauth.register( - name="vegbank_oidc", - client_id=secrets.get("client_id"), - client_secret=secrets.get("client_secret"), - server_metadata_url=secrets.get("server_metadata_url"), - client_kwargs={"scope": scope_request}, - ) - - logger.info("OAuth client initialised.") - return True - - -@functools.lru_cache(maxsize=1) -def get_jwks_keys(): - """Fetch and cache the JWKS signing keys from the OIDC provider. - - These keys are used to validate JWT token signatures. Care must be taken to fetch - them only from trustworthy sources (via the OIDC provider's metadata endpoint over - HTTPS). The keys may change periodically, so the cache will be invalidated and keys - will be refetched on the next call after the application is restarted. - - Returns: - authlib.jose.JsonWebKey: A ``JsonWebKeySet`` ready for ``jwt.decode``. - - Raises: - ValueError: If the OIDC server metadata does not expose a ``jwks_uri``. - requests.RequestException: If errors while fetching the JWKS. - """ - metadata = oauth.vegbank_oidc.load_server_metadata() - jwks_uri = metadata.get("jwks_uri") - if not jwks_uri: - raise ValueError("OIDC provider metadata does not contain 'jwks_uri'") - - response = _requests.get(jwks_uri, timeout=10) - response.raise_for_status() - return JsonWebKey.import_key_set(response.json()) - - -def _extract_bearer_token(): - """Extract the raw JWT string from the - ``Authorization: Bearer …`` header. - - Returns: - str | None: The token string, or ``None`` if the header is absent / malformed. - """ - auth_header = request.headers.get("Authorization", "") - if auth_header.startswith("Bearer "): - token = auth_header[7:] - - # caps the token length to prevent huge tokens from causing DoS issues in downstream processing. - if len(token) > MAX_TOKEN_LEN: - return None # triggers 401 - return token - return None - - -def _decode_and_validate_token(token_str: str): - """Decode *and* full-validate a JWT against the OIDC provider's JWKS. - - Validates signature, issuer (``iss``), audience (``aud``), and authorized-party (``azp``) claims. - - Args: - token_str: Raw JWT string - - Returns: - The validated claims object. - - Raises: - DecodeError: Token could not be decoded. - InvalidTokenError: Signature is valid but one or more claims are - invalid (such as expired tokens). - BadSignatureError: JWKS signature verification failed. - ValueError: ``jwks_uri`` missing from OIDC metadata. - requests.RequestException: Network / HTTP error fetching JWKS. - """ - jwks = get_jwks_keys() - metadata = oauth.vegbank_oidc.load_server_metadata() - issuer = metadata.get("issuer") - - client_id = load_client_secrets().get("client_id") - - claims = jwt.decode( - token_str, - jwks, - claims_options={ - "iss": {"essential": True, "value": issuer}, - "aud": {"essential": True, "value": client_id}, - "azp": {"essential": True, "value": client_id}, - }, - ) - claims.validate() - return claims - - -def _auth_error_response(message, status, details=None): - """Generate a uniform JSON error response for authentication/authorization errors. - - All auth-related error responses should use this helper to guarantee a consistent ``{"error": {"message": ..., "details": ...}}`` object. - - Args: - message: Error description. - status: HTTP status code. - details: Optional additional context (``str(exc)``). Omitted from the response when *None*. - - Returns: - Tuple of (JSON response, status code). - """ - error = {"message": message} - if details is not None: - error["details"] = details - return jsonify({"error": error}), status - - -def _token_error_response(exc): - """Produce a uniform JSON error response for token validation/exchange failures.""" - error_map = { - DecodeError: ("Token decoding failed", 401), - InvalidClientError: ("OIDC client authentication failed", 401), - InvalidTokenError: ("Token validation failed", 401), - InvalidGrantError: ("Invalid or expired refresh token", 401), - BadSignatureError: ("Token signature verification failed", 401), - OAuthError: ("Authorization failed", 401), - OAuth2Error: ("An OAuth2 error occurred", 401), - KeyError: ("Invalid token structure", 401), - TypeError: ("Invalid token structure", 401), - MissingParameterError: ("Missing required parameter", 400), - ValueError: ("OIDC provider configuration error", 500), - _requests.RequestException: ("Failed to fetch OIDC provider keys", 502), - } - for exc_types, (message, status) in error_map.items(): - if isinstance(exc, exc_types): - return _auth_error_response(message, status, details=str(exc)) - # Unexpected exception — treat as server error - return _auth_error_response("Internal authentication error", 500, details=str(exc)) - - -def _token_response(token: dict, message: str = "Token exchange successful"): - """Produce a uniform JSON response with access and refresh tokens. - - Args: - token: Dict containing token data with 'access_token' and 'refresh_token' keys. - message: Optional message to include in response. - - Returns: - Tuple of (JSON response, 200 status code). - """ - return ( - jsonify( - { - "message": message, - "token": { - "access_token": token.get("access_token"), - "refresh_token": token.get("refresh_token"), - }, - } - ), - 200, - ) - - _ORCID_HTTPS_PREFIX = "https://orcid.org/" _ORCID_HTTP_PREFIX = "http://orcid.org/" - +# leave this in as a helper def extract_orcid(claims: dict | None) -> str | None: """Extract a normalised ORCID iD URI from JWT claims. @@ -329,281 +65,7 @@ def extract_orcid(claims: dict | None) -> str | None: return _ORCID_HTTPS_PREFIX + bare - -def _store_user_context(claims): - """Store decoded token claims in request context.""" - g.token_claims = claims - - -def _validate_and_extract_claims(required_scope=None): - """Validate bearer token and optionally check required scope. - - Args: - required_scope: Optional scope string to validate. - - Returns: - Tuple of (claims_dict, error_response_tuple) where error_response_tuple is None on success. - """ - token_str = _extract_bearer_token() - if not token_str: - return None, _auth_error_response("Missing or invalid Authorization header", 401) - - try: - claims = _decode_and_validate_token(token_str) - except (DecodeError, InvalidTokenError, BadSignatureError, ValueError, RequestException) as exc: - return None, _token_error_response(exc) - - # Scope check if required - if required_scope: - token_scopes = claims.get("scope", "").split() - if required_scope not in token_scopes: - return None, _auth_error_response( - f"Insufficient scope. Required: {required_scope}", - 403, - details=f"Available scopes: {' '.join(token_scopes)}", - ) - - return claims, None - - - -def require_token(methods=None): - """Decorator - protect an endpoint that requires *any* valid JWT. - - **Only enforces authentication when accessMode='authenticated'.** - In 'read_only' and 'open' modes, this decorator allows all requests. - - Returns ``401`` if the token is missing, expired, or otherwise invalid. - - Can enforce auth on specific HTTP methods only. If ``methods`` is None, - protects all methods. - - Args: - methods: Optional list of HTTP method names (e.g., ``['POST', 'PUT', 'DELETE']``) to protect. - If None, all methods are protected. - If the current request method is not in the list, auth is skipped. - - Example: - ``@require_token(methods=['POST', 'PUT', 'DELETE'])`` - only protect write operations - """ - def decorator(f): - @functools.wraps(f) - def decorated(*args, **kwargs): - mode = get_access_mode() - - # In read_only or open mode, skip auth entirely - if mode != ACCESS_MODE_AUTHENTICATED: - logger.warning("Access mode '%s': skipping token validation", mode) - return f(None, *args, **kwargs) - - # If methods are specified, only enforce auth for those methods - if methods is not None and request.method not in methods: - # No auth required for this method; pass None as claims - return f(None, *args, **kwargs) - - claims, error = _validate_and_extract_claims() - if error: - return error - - _store_user_context(claims) - return f(claims, *args, **kwargs) - - return decorated - - return decorator - - -def require_scope(required_scope: str, methods=None): - """Decorator factory - protect an endpoint that requires a specific scope. - - **Only enforces authorization when accessMode='authenticated'.** - In 'read_only' and 'open' modes, this decorator allows all requests. - - Supported VegBank scopes: - - * ``vegbank:admin`` - admin ops - * ``vegbank:contributor`` - create/update access for vegbank data - * ``vegbank:user`` - create/update access for user datasets - - Returns ``401`` for missing / invalid tokens, ``403`` if the required scope - is absent from the token. - - Can enforce auth on specific HTTP methods only. If ``methods`` is None, - protects all methods. - - **Claims Parameter Injection:** - - This decorator injects a ``claims`` keyword argument into wrapped functions. - The ``claims`` dict contains user info (e.g., preferred_username, email, scopes) - extracted from the JWT token. Claims are only populated in 'authenticated' mode; - in other modes, claims is None. Route handlers that need audit logging should - accept a ``claims=None`` parameter and check it before use. - - Args: - required_scope: Valid OAuth 2.0 scope string that must be present in the token's ``scope`` claim. - methods: Optional list of HTTP method names (e.g., ``['POST', 'PUT', 'DELETE']``) to protect. - If None, all methods are protected. - If the current request method is not in the list, auth is skipped. - - Example: - ``@require_scope(SCOPE_CONTRIBUTOR, methods=['POST'])`` - only protect POST operations - - Handler Example: - ``def my_handler(vb_code, claims=None):`` - claims are injected as kwargs - """ - def decorator(f): - @functools.wraps(f) - def decorated(*args, **kwargs): - mode = get_access_mode() - - # In read_only or open mode, skip auth entirely - if mode != ACCESS_MODE_AUTHENTICATED: - logger.warning("Access mode '%s': skipping scope validation", mode) - # Store None in g for consistency - g.token_claims = None - return f(*args, **kwargs) - - # If methods are specified, only enforce auth for those methods - if methods is not None and request.method not in methods: - # No auth required for this method; store None as claims - g.token_claims = None - return f(*args, **kwargs) - - claims, error = _validate_and_extract_claims(required_scope=required_scope) - if error: - return error - - _store_user_context(claims) - # Pass claims as keyword argument for explicit access in handlers - kwargs['claims'] = claims - return f(*args, **kwargs) - - return decorated - - return decorator - - -@auth_bp.route("/login", methods=["GET"]) -def login(): - """Initiate the OIDC login flow. - - Sends the user to the provider's login page. After successful - authentication the provider redirects back to the ``/authorize`` - callback. - - Args: - (None) - - Returns: - 302 redirect to the provider's authorization endpoint. - 401/500 JSON error response if login fails. - 403 JSON response if authentication is disabled for the current access mode. - - """ - mode = get_access_mode() - if mode != ACCESS_MODE_AUTHENTICATED: - return _auth_error_response(f"Authentication is disabled in '{mode}' mode.", 403) - - try: - return oauth.vegbank_oidc.authorize_redirect(url_for("main.auth.authorize", _external=True)) - except (OAuthError, RequestException) as exc: - logger.warning("OIDC authorize_redirect error: %s", exc) - return _token_error_response(exc) - - -@auth_bp.route("/authorize", methods=["GET"]) -def authorize(): - """OIDC authorization callback endpoint. - - Keycloak redirects here after a successful login with a short-lived - authorization code. This endpoint exchanges that code for an access - token, stores the token and returns it to the caller. - - Returns: - 200 JSON with ``token`` on success. - 401 JSON with error details on failure. - 403 JSON response if authentication is disabled for the current access mode. - """ - mode = get_access_mode() - if mode != ACCESS_MODE_AUTHENTICATED: - return _auth_error_response(f"Authentication is disabled in '{mode}' mode.", 403) - - try: - token = oauth.vegbank_oidc.authorize_access_token() - except (OAuthError, RequestException) as exc: - logger.debug("OIDC token exchange error: %s", exc) - return _token_error_response(exc) - - return _token_response(token, message="Authorization successful") - - -@auth_bp.route("/refresh", methods=["POST"]) -def refresh_token(): - """Re-validate the user session and return a new access token using the refresh token. - - When an access token expires, the client can call this endpoint with the refresh token - to obtain a new access token without requiring the user to log in again. The client - can also pass the desired scopes for the new access token, which must be a subset - of the original scopes granted to the refresh token. - - Parameters (in JSON body): - - ``refresh_token`` (string, required): The refresh token issued by the OIDC provider. - - ``scope`` (string, optional): Space-separated list of scopes to request for the new access token. If omitted, the new access token will have the same scopes as the original token. - - Returns: - 200 JSON with new ``access_token`` and ``refresh_token`` on success. - 400 JSON if the request is missing required parameters. - 401 JSON if the refresh token is invalid, expired, or if client authentication fails. - 500 JSON for unexpected server errors. - """ - # Get the refresh token and desired scopes from the JSON body - data = request.get_json(silent=True) - if not data: - return _token_error_response(MissingParameterError("refresh_token")) - - user_refresh_token = data.get("refresh_token") - if not user_refresh_token: - return _token_error_response(MissingParameterError("refresh_token")) - - # The client should pass the scopes that it would like to request for the - # new access token. If no scopes are provided, we will attempt to get a - # new access token with the same scopes as the original token. The - # requested scopes must match or be a subset of the original scopes granted - # to the token, otherwise the OIDC provider will reject the request. - requested_scope = data.get("scope") - - # Use Authlib to exchange the refresh token for a new access token - try: - if not requested_scope: - # If no scope is provided, omit the scope parameter to get the same scopes as the original token - new_tokens = oauth.vegbank_oidc.fetch_access_token( - grant_type="refresh_token", - refresh_token=user_refresh_token, - ) - else: - new_tokens = oauth.vegbank_oidc.fetch_access_token( - grant_type="refresh_token", - refresh_token=user_refresh_token, - scope=requested_scope, - ) - return _token_response(new_tokens, message="Authorization successful") - except InvalidGrantError as exc: - # The refresh token was invalid, expired, or revoked by the provider - logger.debug("The refresh token is invalid or expired: %s", exc) - return _token_error_response(exc) - except InvalidClientError as exc: - # The client_id or client_secret is wrong - logger.warning("OIDC client authentication failed: %s", exc) - return _token_error_response(exc) - except OAuth2Error as exc: - logger.debug("An OAuth2 error occurred: %s", exc) - return _token_error_response(exc) - except Exception as exc: - # A safety net for non-OAuth errors (e.g., network issues) - logger.error("Unexpected Exception during refresh: %s", exc, exc_info=True) - return _token_error_response(exc) - - +# probably remove def get_access_mode() -> str: """Get the current access mode from environment. From d16c62a62a0de6d3bdcf55f86082e4223a981d7d Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 8 May 2026 11:23:38 -0700 Subject: [PATCH 08/63] rename this file --- src/dataone/{auth.py => utils.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/dataone/{auth.py => utils.py} (100%) diff --git a/src/dataone/auth.py b/src/dataone/utils.py similarity index 100% rename from src/dataone/auth.py rename to src/dataone/utils.py From fbb156bac035df17270603e5a33f1855d0c13747 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 8 May 2026 11:24:51 -0700 Subject: [PATCH 09/63] add example application code doc --- docs/app-code.md | 372 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 372 insertions(+) create mode 100644 docs/app-code.md diff --git a/docs/app-code.md b/docs/app-code.md new file mode 100644 index 0000000..badcb58 --- /dev/null +++ b/docs/app-code.md @@ -0,0 +1,372 @@ +# Changes to application code + +## Flask + +### Initialize client + + +``` +from werkzeug.middleware.proxy_fix import ProxyFix +from flask import current_app, g +# Assuming the user imports your factory +from dataone.factory import AuthFactory +from dataone.utils import load_client_secrets + +def init_oauth(app) -> bool: + """Initialise the OAuth client and register the OIDC provider.""" + + mode = get_access_mode() + if mode != ACCESS_MODE_AUTHENTICATED: + logger.warning("Access mode '%s': skipping OAuth initialisation.", mode) + return True + + try: + vb_secrets = load_client_secrets() + except (FileNotFoundError, json.JSONDecodeError) as exc: + logger.warning("Could not load client secrets (%s). Auth unavailable.", exc) + return False + + if not isinstance(app.wsgi_app, ProxyFix): + app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1) + + vb_scopes = [SCOPE_ADMIN, SCOPE_CONTRIBUTOR, SCOPE_USER] + + auth_client = AuthFactory.create_client("flask", vb_secrets, vb_scopes) + + auth_client.init_app(app) + + # attach to app context so Flask routes can access it later + app.extensions['dataone_auth'] = auth_client + + logger.info("OAuth client initialised.") + return True +``` + +### Protect endpoints with decorator + +``` +def require_token(methods=None, required_scope=None): + def decorator(f): + @functools.wraps(f) + def decorated(*args, **kwargs): + mode = get_access_mode() + if mode != ACCESS_MODE_AUTHENTICATED: + return f(None, *args, **kwargs) + + if methods is not None and request.method not in methods: + return f(None, *args, **kwargs) + + # Framework specific: Extract the token + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + token = auth_header[7:] + + # caps the token length to prevent huge tokens from causing DoS issues in downstream processing. + if len(token) > MAX_TOKEN_LEN: + return None # triggers 401 + + adapter = current_app.extensions['dataone_auth'] + + try: + claims = adapter.validate_and_extract_claims(token_str, required_scope) + except InsufficientScopeError as e: + # Framework specific: Return 403 Forbidden + return jsonify({"error": str(e)}), 403 + except Exception as e: + # Framework specific: Return 401 Unauthorized + return jsonify({"error": f"Invalid token: {str(e)}"}), 401 + + g.token_claims = claims + return f(claims, *args, **kwargs) + + return decorated + return decorator + +``` + +``` +def require_scope(required_scope: str, methods=None): + def decorator(f): + @functools.wraps(f) + def decorated(*args, **kwargs): + mode = get_access_mode() + + # In read_only or open mode, skip auth entirely + if mode != ACCESS_MODE_AUTHENTICATED: + logger.warning("Access mode '%s': skipping scope validation", mode) + # Store None in g for consistency + g.token_claims = None + return f(*args, **kwargs) + + # If methods are specified, only enforce auth for those methods + if methods is not None and request.method not in methods: + # No auth required for this method; store None as claims + g.token_claims = None + return f(*args, **kwargs) + + adapter = current_app.extensions['dataone_auth'] + + claims, error = adapter.validate_and_extract_claims(required_scope=required_scope) + if error: + return error + + g.token_claims = claims + # Pass claims as keyword argument for explicit access in handlers + kwargs['claims'] = claims + return f(*args, **kwargs) + + return decorated + + return decorator +``` + +### API Endpoints + +**login** + +``` +@auth_bp.route("/login", methods=["GET"]) +def login(): + """Initiate the OIDC login flow. + + Sends the user to the provider's login page. After successful + authentication the provider redirects back to the ``/authorize`` + callback. + + Args: + (None) + + Returns: + 302 redirect to the provider's authorization endpoint. + 401/500 JSON error response if login fails. + 403 JSON response if authentication is disabled for the current access mode. + + """ + mode = get_access_mode() + if mode != ACCESS_MODE_AUTHENTICATED: + return _auth_error_response(f"Authentication is disabled in '{mode}' mode.", 403) + + adapter = current_app.extensions['dataone_auth'] + oidc_client = adapter.vegbank_oidc # maybe get this dynamically + + try: + return adapter.authorize_redirect(url_for("main.auth.authorize", _external=True)) + except (OAuthError, RequestException) as exc: + logger.warning("OIDC authorize_redirect error: %s", exc) + return _token_error_response(exc) + +``` + +**refresh** + +``` +@auth_bp.route("/refresh", methods=["POST"]) +def refresh_token(): + """Re-validate the user session and return a new access token using the refresh token. + + When an access token expires, the client can call this endpoint with the refresh token + to obtain a new access token without requiring the user to log in again. The client + can also pass the desired scopes for the new access token, which must be a subset + of the original scopes granted to the refresh token. + + Parameters (in JSON body): + - ``refresh_token`` (string, required): The refresh token issued by the OIDC provider. + - ``scope`` (string, optional): Space-separated list of scopes to request for the new access token. If omitted, the new access token will have the same scopes as the original token. + + Returns: + 200 JSON with new ``access_token`` and ``refresh_token`` on success. + 400 JSON if the request is missing required parameters. + 401 JSON if the refresh token is invalid, expired, or if client authentication fails. + 500 JSON for unexpected server errors. + """ + + adapter = current_app.extensions.get('dataone_auth') + + # Get the refresh token and desired scopes from the JSON body + data = request.get_json(silent=True) + if not data: + return _token_error_response(MissingParameterError("refresh_token")) + + user_refresh_token = data.get("refresh_token") + if not user_refresh_token: + return _token_error_response(MissingParameterError("refresh_token")) + + # The client should pass the scopes that it would like to request for the + # new access token. If no scopes are provided, we will attempt to get a + # new access token with the same scopes as the original token. The + # requested scopes must match or be a subset of the original scopes granted + # to the token, otherwise the OIDC provider will reject the request. + requested_scope = data.get("scope") + + # Use Authlib to exchange the refresh token for a new access token + try: + oidc_client = adapter.vegbank_oidc # maybe get this dynamically + if not requested_scope: + # If no scope is provided, omit the scope parameter to get the same scopes as the original token + new_tokens = oidc_client.fetch_access_token( + grant_type="refresh_token", + refresh_token=user_refresh_token, + ) + else: + new_tokens = oidc_client.fetch_access_token( + grant_type="refresh_token", + refresh_token=user_refresh_token, + scope=requested_scope, + ) + return _token_response(new_tokens, message="Authorization successful") + except InvalidGrantError as exc: + # The refresh token was invalid, expired, or revoked by the provider + logger.debug("The refresh token is invalid or expired: %s", exc) + return _token_error_response(exc) + except InvalidClientError as exc: + # The client_id or client_secret is wrong + logger.warning("OIDC client authentication failed: %s", exc) + return _token_error_response(exc) + except OAuth2Error as exc: + logger.debug("An OAuth2 error occurred: %s", exc) + return _token_error_response(exc) + except Exception as exc: + # A safety net for non-OAuth errors (e.g., network issues) + logger.error("Unexpected Exception during refresh: %s", exc, exc_info=True) + return _token_error_response(exc) + +``` + +**authorize** + +``` + +@auth_bp.route("/authorize", methods=["GET"]) +def authorize(): + """OIDC authorization callback endpoint. + + Keycloak redirects here after a successful login with a short-lived + authorization code. This endpoint exchanges that code for an access + token, stores the token and returns it to the caller. + + Returns: + 200 JSON with ``token`` on success. + 401 JSON with error details on failure. + 403 JSON response if authentication is disabled for the current access mode. + """ + mode = get_access_mode() + if mode != ACCESS_MODE_AUTHENTICATED: + return _auth_error_response(f"Authentication is disabled in '{mode}' mode.", 403) + + adapter = current_app.extensions.get('dataone_auth') + oidc_client = adapter.vegbank_oidc + + try: + token = oidc_client.authorize_access_token() + except (OAuthError, RequestException) as exc: + logger.debug("OIDC token exchange error: %s", exc) + return _token_error_response(exc) + + return _token_response(token, message="Authorization successful") + +``` + +### Response/Error Classes + +``` +def _auth_error_response(message, status, details=None): + """Generate a uniform JSON error response for authentication/authorization errors. + + All auth-related error responses should use this helper to guarantee a consistent ``{"error": {"message": ..., "details": ...}}`` object. + + Args: + message: Error description. + status: HTTP status code. + details: Optional additional context (``str(exc)``). Omitted from the response when *None*. + + Returns: + Tuple of (JSON response, status code). + """ + error = {"message": message} + if details is not None: + error["details"] = details + return jsonify({"error": error}), status + + +def _token_error_response(exc): + """Produce a uniform JSON error response for token validation/exchange failures.""" + error_map = { + DecodeError: ("Token decoding failed", 401), + InvalidClientError: ("OIDC client authentication failed", 401), + InvalidTokenError: ("Token validation failed", 401), + InvalidGrantError: ("Invalid or expired refresh token", 401), + BadSignatureError: ("Token signature verification failed", 401), + OAuthError: ("Authorization failed", 401), + OAuth2Error: ("An OAuth2 error occurred", 401), + KeyError: ("Invalid token structure", 401), + TypeError: ("Invalid token structure", 401), + MissingParameterError: ("Missing required parameter", 400), + ValueError: ("OIDC provider configuration error", 500), + _requests.RequestException: ("Failed to fetch OIDC provider keys", 502), + } + for exc_types, (message, status) in error_map.items(): + if isinstance(exc, exc_types): + return _auth_error_response(message, status, details=str(exc)) + # Unexpected exception — treat as server error + return _auth_error_response("Internal authentication error", 500, details=str(exc)) + + +def _token_response(token: dict, message: str = "Token exchange successful"): + """Produce a uniform JSON response with access and refresh tokens. + + Args: + token: Dict containing token data with 'access_token' and 'refresh_token' keys. + message: Optional message to include in response. + + Returns: + Tuple of (JSON response, 200 status code). + """ + return ( + jsonify( + { + "message": message, + "token": { + "access_token": token.get("access_token"), + "refresh_token": token.get("refresh_token"), + }, + } + ), + 200, + ) + +``` + +## FastAPI + +``` +from fastapi import FastAPI +from dataone.factory import AuthFactory +from dataone.auth import load_client_secrets +import logging + +logger = logging.getLogger(__name__) + +# 1. Create the FastAPI instance +app = FastAPI() + +def init_auth_client(): + """Initialise the DataOne Auth Client for FastAPI.""" + + try: + ogdc_secrets = load_client_secrets() + except Exception as exc: + logger.warning("Auth unavailable: %s", exc) + return None + + # Define ogdc-specific scopes if they differ, or use defaults + ogdc_scopes = ["ogdc:admin", "ogdc:user"] + + # This might return an 'httpx' based Async client instead of a 'requests' one + auth_client = AuthFactory.create_client("fastapi", ogdc_secrets, ogdc_scopes) + + return auth_client + +# 3. Store it in the app state for easy access +app.state.auth = init_auth_client() + +``` \ No newline at end of file From 2deb34b671ed3e82268d8c74b5df94af46ee8cb1 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 8 May 2026 11:45:07 -0700 Subject: [PATCH 10/63] fix import --- tests/test_auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_auth.py b/tests/test_auth.py index 556180f..870bc0a 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,6 +1,6 @@ """Unit tests for auth.py helpers.""" -from dataone.auth import extract_orcid +from dataone.utils import extract_orcid def test_extract_orcid_returns_https_uri_from_https_orcid_claim(): From 39f69ae36cd2b668bdd792491d5913c01ab99f2f Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 8 May 2026 13:57:40 -0700 Subject: [PATCH 11/63] restructure to only have one module --- src/dataone/adapters/fastapi.py | 6 -- src/dataone/adapters/flask.py | 6 -- src/dataone/{adapters/base.py => auth.py} | 125 +++++++++++++++++++++- src/dataone/factory.py | 19 ---- src/dataone/utils.py | 79 -------------- tests/test_auth.py | 40 ++++++- tests/test_factory.py | 44 -------- 7 files changed, 162 insertions(+), 157 deletions(-) delete mode 100644 src/dataone/adapters/fastapi.py delete mode 100644 src/dataone/adapters/flask.py rename src/dataone/{adapters/base.py => auth.py} (52%) delete mode 100644 src/dataone/factory.py delete mode 100644 src/dataone/utils.py delete mode 100644 tests/test_factory.py diff --git a/src/dataone/adapters/fastapi.py b/src/dataone/adapters/fastapi.py deleted file mode 100644 index 11e27c8..0000000 --- a/src/dataone/adapters/fastapi.py +++ /dev/null @@ -1,6 +0,0 @@ -from .base import BaseAuthAdapter - -class FastAPIAuthAdapter(BaseAuthAdapter): - def _initialize_oauth(self): - from authlib.integrations.starlette_client import OAuth - return OAuth() \ No newline at end of file diff --git a/src/dataone/adapters/flask.py b/src/dataone/adapters/flask.py deleted file mode 100644 index 897c5a9..0000000 --- a/src/dataone/adapters/flask.py +++ /dev/null @@ -1,6 +0,0 @@ -from .base import BaseAuthAdapter - -class FlaskAuthAdapter(BaseAuthAdapter): - def _initialize_oauth(self): - from authlib.integrations.flask_client import OAuth - return OAuth() \ No newline at end of file diff --git a/src/dataone/adapters/base.py b/src/dataone/auth.py similarity index 52% rename from src/dataone/adapters/base.py rename to src/dataone/auth.py index 1a9e0fd..0c77745 100644 --- a/src/dataone/adapters/base.py +++ b/src/dataone/auth.py @@ -1,6 +1,119 @@ +import os +import re +import json import requests from authlib.jose import jwt, JsonWebKey + +class MissingParameterError(Exception): + """Raised when a required request parameter is missing.""" + + +def load_client_secrets(filepath: str | None = None) -> dict: + """Load client secrets from a JSON file. + + Args: + filepath: Optional explicit path. Falls back to the + ``OIDC_CLIENT_SECRETS_FILE`` environment variable + + Returns: + Parsed dict of client credentials. + """ + # accept either explicit filepath argument or environment variable, with a default fallback + resolved = ( + filepath + or os.getenv("OIDC_CLIENT_SECRETS_FILE") + or _DEFAULT_SECRETS_PATH + ) + with open(resolved, "r") as f: + return json.load(f) + +def extract_token_from_header(auth_header: str): + """Extracts and safely bounds a Bearer token from an Authorization header.""" + + if not auth_header or not auth_header.startswith("Bearer "): + return None + + token = auth_header[7:].strip() + + # caps the token length to prevent huge tokens from causing DoS issues in downstream processing. + if len(token) > MAX_TOKEN_LEN: + return None + + return token + +_ORCID_HTTPS_PREFIX = "https://orcid.org/" +_ORCID_HTTP_PREFIX = "http://orcid.org/" + +# leave this in as a helper +def extract_orcid(claims: dict | None) -> str | None: + """Extract a normalised ORCID iD URI from JWT claims. + + Reads the ``orcid`` claim. The returned value is always the canonical + HTTPS URI form (``https://orcid.org/XXXX-XXXX-XXXX-XXXX``). + + Args: + claims: Decoded JWT claims dict, or ``None``. + + Returns: + Canonical ORCID URI (e.g. ``"https://orcid.org/0000-0002-1825-0097"``), + or ``None`` if the ``orcid`` claim is absent or malformed. + """ + if not claims: + return None + + raw = claims.get("orcid") + + if not raw or not isinstance(raw, str): + return None + + # Strip http(s)://orcid.org/ prefix, leaving just the bare ID + if raw.startswith(_ORCID_HTTPS_PREFIX): + bare = raw[len(_ORCID_HTTPS_PREFIX):] + elif raw.startswith(_ORCID_HTTP_PREFIX): + bare = raw[len(_ORCID_HTTP_PREFIX):] + else: + bare = raw + + # Validate: XXXX-XXXX-XXXX-XXXX where the last character may be X (checksum digit) + if not re.fullmatch(r"\d{4}-\d{4}-\d{4}-\d{3}[0-9X]", bare): + return None + + return _ORCID_HTTPS_PREFIX + bare + +# probably remove +def get_access_mode() -> str: + """Get the current access mode from environment. + + Returns: + str: One of 'read_only', 'open', or 'authenticated'. Defaults to 'authenticated'. + """ + mode = os.getenv("VB_ACCESS_MODE", ACCESS_MODE_AUTHENTICATED).lower() + if mode not in (ACCESS_MODE_READ_ONLY, ACCESS_MODE_OPEN, ACCESS_MODE_AUTHENTICATED): + logger.warning(f"Invalid access mode '{mode}', falling back to '{ACCESS_MODE_AUTHENTICATED}'") + return ACCESS_MODE_AUTHENTICATED + return mode + +class AuthFactory: + + _registry = { + "flask": "dataone.auth.FlaskAuthAdapter", + "fastapi": "dataone.auth.FastAPIAuthAdapter", + "starlette": "dataone.auth.FastAPIAuthAdapter", + } + + @classmethod + def create_client(cls, framework: str, secrets: dict, scopes: list): + import_path = cls._registry.get(framework.lower()) + if not import_path: + raise ValueError(f"Unsupported framework: {framework}") + + module_path, class_name = import_path.rsplit(".", 1) + module = __import__(module_path, fromlist=[class_name]) + AdapterClass = getattr(module, class_name) + + return AdapterClass(secrets=secrets, scopes=scopes) + class BaseAuthAdapter: DEFAULT_PROVIDER_NAME = "vegbank_oidc" @@ -123,4 +236,14 @@ def __getattr__(self, name): Delegate all unknown attribute/method lookups to the underlying Authlib OAuth object. This automatically exposes .register(), .init_app(), etc. """ - return getattr(self.oauth, name) \ No newline at end of file + return getattr(self.oauth, name) + +class FastAPIAuthAdapter(BaseAuthAdapter): + def _initialize_oauth(self): + from authlib.integrations.starlette_client import OAuth + return OAuth() + +class FlaskAuthAdapter(BaseAuthAdapter): + def _initialize_oauth(self): + from authlib.integrations.flask_client import OAuth + return OAuth() \ No newline at end of file diff --git a/src/dataone/factory.py b/src/dataone/factory.py deleted file mode 100644 index 13fd65c..0000000 --- a/src/dataone/factory.py +++ /dev/null @@ -1,19 +0,0 @@ -class AuthFactory: - - _registry = { - "flask": "dataone.adapters.flask.FlaskAuthAdapter", - "fastapi": "dataone.adapters.fastapi.FastAPIAuthAdapter", - "starlette": "dataone.adapters.fastapi.FastAPIAuthAdapter", - } - - @classmethod - def create_client(cls, framework: str, secrets: dict, scopes: list): - import_path = cls._registry.get(framework.lower()) - if not import_path: - raise ValueError(f"Unsupported framework: {framework}") - - module_path, class_name = import_path.rsplit(".", 1) - module = __import__(module_path, fromlist=[class_name]) - AdapterClass = getattr(module, class_name) - - return AdapterClass(secrets=secrets, scopes=scopes) \ No newline at end of file diff --git a/src/dataone/utils.py b/src/dataone/utils.py deleted file mode 100644 index 28228a0..0000000 --- a/src/dataone/utils.py +++ /dev/null @@ -1,79 +0,0 @@ -import os -import re - - -class MissingParameterError(Exception): - """Raised when a required request parameter is missing.""" - - -def load_client_secrets(filepath: str | None = None) -> dict: - """Load client secrets from a JSON file. - - Args: - filepath: Optional explicit path. Falls back to the - ``OIDC_CLIENT_SECRETS_FILE`` environment variable - - Returns: - Parsed dict of client credentials. - """ - # accept either explicit filepath argument or environment variable, with a default fallback - resolved = ( - filepath - or os.getenv("OIDC_CLIENT_SECRETS_FILE") - or _DEFAULT_SECRETS_PATH - ) - with open(resolved, "r") as f: - return json.load(f) - - -_ORCID_HTTPS_PREFIX = "https://orcid.org/" -_ORCID_HTTP_PREFIX = "http://orcid.org/" - -# leave this in as a helper -def extract_orcid(claims: dict | None) -> str | None: - """Extract a normalised ORCID iD URI from JWT claims. - - Reads the ``orcid`` claim. The returned value is always the canonical - HTTPS URI form (``https://orcid.org/XXXX-XXXX-XXXX-XXXX``). - - Args: - claims: Decoded JWT claims dict, or ``None``. - - Returns: - Canonical ORCID URI (e.g. ``"https://orcid.org/0000-0002-1825-0097"``), - or ``None`` if the ``orcid`` claim is absent or malformed. - """ - if not claims: - return None - - raw = claims.get("orcid") - - if not raw or not isinstance(raw, str): - return None - - # Strip http(s)://orcid.org/ prefix, leaving just the bare ID - if raw.startswith(_ORCID_HTTPS_PREFIX): - bare = raw[len(_ORCID_HTTPS_PREFIX):] - elif raw.startswith(_ORCID_HTTP_PREFIX): - bare = raw[len(_ORCID_HTTP_PREFIX):] - else: - bare = raw - - # Validate: XXXX-XXXX-XXXX-XXXX where the last character may be X (checksum digit) - if not re.fullmatch(r"\d{4}-\d{4}-\d{4}-\d{3}[0-9X]", bare): - return None - - return _ORCID_HTTPS_PREFIX + bare - -# probably remove -def get_access_mode() -> str: - """Get the current access mode from environment. - - Returns: - str: One of 'read_only', 'open', or 'authenticated'. Defaults to 'authenticated'. - """ - mode = os.getenv("VB_ACCESS_MODE", ACCESS_MODE_AUTHENTICATED).lower() - if mode not in (ACCESS_MODE_READ_ONLY, ACCESS_MODE_OPEN, ACCESS_MODE_AUTHENTICATED): - logger.warning(f"Invalid access mode '{mode}', falling back to '{ACCESS_MODE_AUTHENTICATED}'") - return ACCESS_MODE_AUTHENTICATED - return mode \ No newline at end of file diff --git a/tests/test_auth.py b/tests/test_auth.py index 870bc0a..05f02fb 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,6 +1,7 @@ """Unit tests for auth.py helpers.""" - -from dataone.utils import extract_orcid +import pytest +from dataone.auth import extract_orcid +from dataone.auth import AuthFactory def test_extract_orcid_returns_https_uri_from_https_orcid_claim(): @@ -29,3 +30,38 @@ def test_extract_orcid_returns_none_for_none_input(): def test_extract_orcid_returns_none_for_empty_claims(): """Test that extract_orcid returns None when called with an empty claims dict.""" assert extract_orcid({}) is None + +MOCK_SECRETS = { + "client_id": "test client", + "client_secret": "a string", + "server_metadata_url": "https://url.com", +} + +MOCK_SCOPES = ["vegbank:admin", "vegbank:contributor", "vegbank:user"] + +def test_factory_returns_flask_adapter(): + # Skip test if Flask isn't installed in this environment + pytest.importorskip("flask") + + from dataone.auth import FlaskAuthAdapter + + adapter = AuthFactory.create_client("flask", secrets=MOCK_SECRETS, scopes=MOCK_SCOPES) + + assert isinstance(adapter, FlaskAuthAdapter) + assert adapter.secrets == MOCK_SECRETS + +def test_factory_returns_fastapi_adapter(): + # Skip test if Starlette/FastAPI aren't installed in this environment + pytest.importorskip("starlette") + + from dataone.auth import FastAPIAuthAdapter + + adapter = AuthFactory.create_client("fastapi", secrets=MOCK_SECRETS, scopes=MOCK_SCOPES) + + assert isinstance(adapter, FastAPIAuthAdapter) + assert adapter.secrets == MOCK_SECRETS + +def test_factory_raises_error_on_unknown_framework(): + + with pytest.raises(ValueError, match="Unsupported framework"): + AuthFactory.create_client("django", secrets=MOCK_SECRETS, scopes=MOCK_SCOPES) \ No newline at end of file diff --git a/tests/test_factory.py b/tests/test_factory.py deleted file mode 100644 index f72d26c..0000000 --- a/tests/test_factory.py +++ /dev/null @@ -1,44 +0,0 @@ -# tests/test_factory.py -import pytest -from dataone.factory import AuthFactory - -# Mock config to pass into our adapters -MOCK_SECRETS = { - "client_id": "test client", - "client_secret": "a string", - "server_metadata_url": "https://url.com", -} - -MOCK_SCOPES = ["vegbank:admin", "vegbank:contributor", "vegbank:user"] - -def test_factory_returns_flask_adapter(): - # Skip test if Flask isn't installed in this environment - pytest.importorskip("flask") - - # Import inside the test to avoid top-level crashes - from dataone.adapters.flask import FlaskAuthAdapter - - # Act - adapter = AuthFactory.create_client("flask", secrets=MOCK_SECRETS, scopes=MOCK_SCOPES) - - # Assert - assert isinstance(adapter, FlaskAuthAdapter) - assert adapter.secrets == MOCK_SECRETS - -def test_factory_returns_fastapi_adapter(): - # Skip test if Starlette/FastAPI aren't installed in this environment - pytest.importorskip("starlette") - - from dataone.adapters.fastapi import FastAPIAuthAdapter - - # Act - adapter = AuthFactory.create_client("fastapi", secrets=MOCK_SECRETS, scopes=MOCK_SCOPES) - - # Assert - assert isinstance(adapter, FastAPIAuthAdapter) - assert adapter.secrets == MOCK_SECRETS - -def test_factory_raises_error_on_unknown_framework(): - # Act & Assert - with pytest.raises(ValueError, match="Unsupported framework"): - AuthFactory.create_client("django", secrets=MOCK_SECRETS, scopes=MOCK_SCOPES) \ No newline at end of file From 1c611967aff38f53311d6c635691eb0e39cd5b46 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 8 May 2026 15:41:02 -0700 Subject: [PATCH 12/63] add self to jwts keys --- src/dataone/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 0c77745..327c1a3 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -141,7 +141,7 @@ def _setup_providers(self): client_kwargs={"scope": scope_request}, ) - def get_jwks_keys(): + def get_jwks_keys(self): """Fetch and cache the JWKS signing keys from the OIDC provider. These keys are used to validate JWT token signatures. Care must be taken to fetch From 0c3f3e7f7adb34b62fd2882307ec0cefd178d6fa Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 8 May 2026 15:56:04 -0700 Subject: [PATCH 13/63] add some missing default params --- src/dataone/auth.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 327c1a3..e192930 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -4,6 +4,8 @@ import requests from authlib.jose import jwt, JsonWebKey +MAX_TOKEN_LEN = 16_384 +_DEFAULT_SECRETS_PATH = "./client_secrets.json" class MissingParameterError(Exception): """Raised when a required request parameter is missing.""" From 789101ac8172c029007a60729024a420ba8e5108 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 8 May 2026 16:07:33 -0700 Subject: [PATCH 14/63] improve error handling --- src/dataone/auth.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index e192930..ed42168 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -240,6 +240,8 @@ def __getattr__(self, name): """ return getattr(self.oauth, name) +# adapters + class FastAPIAuthAdapter(BaseAuthAdapter): def _initialize_oauth(self): from authlib.integrations.starlette_client import OAuth @@ -248,4 +250,14 @@ def _initialize_oauth(self): class FlaskAuthAdapter(BaseAuthAdapter): def _initialize_oauth(self): from authlib.integrations.flask_client import OAuth - return OAuth() \ No newline at end of file + return OAuth() + +# exceptions + +class AuthError(Exception): + """Base exception for dataone-auth""" + pass + +class InsufficientScopeError(AuthError): + """Raised when the token is valid but doesn't have the right scope""" + pass \ No newline at end of file From 260449ec5fc9c5584b2d5541c77640b5e03ff0ce Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Mon, 11 May 2026 14:58:03 -0700 Subject: [PATCH 15/63] improve error catching --- src/dataone/auth.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index ed42168..73593a7 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -33,8 +33,13 @@ def load_client_secrets(filepath: str | None = None) -> dict: def extract_token_from_header(auth_header: str): """Extracts and safely bounds a Bearer token from an Authorization header.""" + # check there is a token if not auth_header or not auth_header.startswith("Bearer "): return None + + # make sure it looks like a JWT token + if token.count('.') != 2: + return None token = auth_header[7:].strip() @@ -219,16 +224,13 @@ def validate_and_extract_claims(self, token_str: str, required_scope: str = None Exception: JoseError from Authlib if token is invalid/expired. InsufficientScopeError: If the token lacks the required scope. """ - # 1. Do the crypto math (the method we wrote previously) claims = self.decode_and_validate_token(token_str) - # 2. Scope check if required if required_scope: token_scopes = claims.get("scope", "").split() if required_scope not in token_scopes: raise InsufficientScopeError( - f"Insufficient scope. Required: {required_scope}. " - f"Available: {' '.join(token_scopes)}" + f"Required: '{target_scope}'. Available: {[s for s in token_scopes]}" ) return claims From dcdf6bf259088833cdd6149338086aac01b5be04 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Mon, 11 May 2026 15:00:45 -0700 Subject: [PATCH 16/63] fix the code... --- src/dataone/auth.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 73593a7..64ff40c 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -36,13 +36,13 @@ def extract_token_from_header(auth_header: str): # check there is a token if not auth_header or not auth_header.startswith("Bearer "): return None - + + token = auth_header[7:].strip() + # make sure it looks like a JWT token if token.count('.') != 2: return None - token = auth_header[7:].strip() - # caps the token length to prevent huge tokens from causing DoS issues in downstream processing. if len(token) > MAX_TOKEN_LEN: return None From 07ace970832faa1f9e322d02e96ac8c4453e6fd3 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Tue, 12 May 2026 09:03:13 -0700 Subject: [PATCH 17/63] fix var name --- src/dataone/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 64ff40c..431727f 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -230,7 +230,7 @@ def validate_and_extract_claims(self, token_str: str, required_scope: str = None token_scopes = claims.get("scope", "").split() if required_scope not in token_scopes: raise InsufficientScopeError( - f"Required: '{target_scope}'. Available: {[s for s in token_scopes]}" + f"Required: '{required_scope}'. Available: {[s for s in token_scopes]}" ) return claims From b3f6f6f051e4aa296fc2283b3794e27b8da81009 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Tue, 12 May 2026 09:16:03 -0700 Subject: [PATCH 18/63] change default provider name to dataone_oidc --- src/dataone/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 431727f..1b27972 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -123,7 +123,7 @@ def create_client(cls, framework: str, secrets: dict, scopes: list): class BaseAuthAdapter: - DEFAULT_PROVIDER_NAME = "vegbank_oidc" + DEFAULT_PROVIDER_NAME = "dataone_oidc" DEFAULT_SCOPES = "openid email profile" def __init__(self, secrets, scopes): From b6eb238f1a20b60b9806e21a3582e7e4e47576cf Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Tue, 12 May 2026 15:33:58 -0700 Subject: [PATCH 19/63] change access mode param --- src/dataone/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 1b27972..0e5717c 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -95,7 +95,7 @@ def get_access_mode() -> str: Returns: str: One of 'read_only', 'open', or 'authenticated'. Defaults to 'authenticated'. """ - mode = os.getenv("VB_ACCESS_MODE", ACCESS_MODE_AUTHENTICATED).lower() + mode = os.getenv("ACCESS_MODE", "authenticated").lower() if mode not in (ACCESS_MODE_READ_ONLY, ACCESS_MODE_OPEN, ACCESS_MODE_AUTHENTICATED): logger.warning(f"Invalid access mode '{mode}', falling back to '{ACCESS_MODE_AUTHENTICATED}'") return ACCESS_MODE_AUTHENTICATED From d2716250892eea9845758fd0bf3e7e0801247cfd Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Tue, 12 May 2026 15:47:28 -0700 Subject: [PATCH 20/63] add access modes --- src/dataone/auth.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 0e5717c..9299b8f 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -7,6 +7,10 @@ MAX_TOKEN_LEN = 16_384 _DEFAULT_SECRETS_PATH = "./client_secrets.json" +ACCESS_MODE_AUTHENTICATED = "authenticated" +ACCESS_MODE_READ_ONLY = "read_only" +ACCESS_MODE_OPEN = "open" + class MissingParameterError(Exception): """Raised when a required request parameter is missing.""" From 6be7143498f2a6cbfb5a9d79751b8cc50ccc6c0a Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Tue, 12 May 2026 16:08:36 -0700 Subject: [PATCH 21/63] override baseauth for fastAPI to await appropriately --- src/dataone/auth.py | 57 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 9299b8f..e885ff4 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -253,6 +253,63 @@ def _initialize_oauth(self): from authlib.integrations.starlette_client import OAuth return OAuth() + async def get_jwks_keys(self): + """Async override for fetching JWKS.""" + if hasattr(self, '_cached_jwks'): + return self._cached_jwks + + provider = getattr(self.oauth, self.DEFAULT_PROVIDER_NAME) + # Starlette requires await here + metadata = await provider.load_server_metadata() + + jwks_uri = metadata.get("jwks_uri") + if not jwks_uri: + raise ValueError("OIDC provider metadata missing 'jwks_uri'") + + # Non-blocking HTTP request + async with httpx.AsyncClient() as client: + response = await client.get(jwks_uri, timeout=10) + response.raise_for_status() + + self._cached_jwks = JsonWebKey.import_key_set(response.json()) + return self._cached_jwks + + async def decode_and_validate_token(self, token_str: str): + """Async override for decoding.""" + jwks = await self.get_jwks_keys() + + provider = getattr(self.oauth, self.DEFAULT_PROVIDER_NAME) + # Starlette requires await here too + metadata = await provider.load_server_metadata() + issuer = metadata.get("issuer") + + client_id = self.secrets.get("client_id") + + claims = jwt.decode( + token_str, + jwks, + claims_options={ + "iss": {"essential": True, "value": issuer}, + "aud": {"essential": True, "value": client_id}, + "azp": {"essential": True, "value": client_id}, + }, + ) + claims.validate() + return claims + + async def validate_and_extract_claims(self, token_str: str, required_scope: str = None): + """Async override for claim extraction.""" + claims = await self.decode_and_validate_token(token_str) + + if required_scope: + token_scopes = claims.get("scope", "").split() + if required_scope not in token_scopes: + raise InsufficientScopeError( + f"Required: '{required_scope}'. Available: {[s for s in token_scopes]}" + ) + + return claims + class FlaskAuthAdapter(BaseAuthAdapter): def _initialize_oauth(self): from authlib.integrations.flask_client import OAuth From 04112df7d1a87bec467df7cad9c622de1e9891b0 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Tue, 12 May 2026 16:11:24 -0700 Subject: [PATCH 22/63] ruff fixes --- src/dataone/auth.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index e885ff4..e0e637c 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -1,8 +1,9 @@ +import json import os import re -import json + import requests -from authlib.jose import jwt, JsonWebKey +from authlib.jose import JsonWebKey, jwt MAX_TOKEN_LEN = 16_384 _DEFAULT_SECRETS_PATH = "./client_secrets.json" @@ -31,7 +32,7 @@ def load_client_secrets(filepath: str | None = None) -> dict: or os.getenv("OIDC_CLIENT_SECRETS_FILE") or _DEFAULT_SECRETS_PATH ) - with open(resolved, "r") as f: + with open(resolved) as f: return json.load(f) def extract_token_from_header(auth_header: str): From 542762e40de722e6380c6f03fc15bfbfec850022 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Tue, 12 May 2026 16:12:11 -0700 Subject: [PATCH 23/63] add httpx --- pyproject.toml | 1 + src/dataone/auth.py | 2 ++ uv.lock | 2 ++ 3 files changed, 5 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 484696e..d81b525 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ requires-python = ">=3.13" dependencies = [ "authlib>=1.7.2", "flask>=3.1.3", + "httpx>=0.28.1", "requests>=2.33.1", "werkzeug>=3.1.8", ] diff --git a/src/dataone/auth.py b/src/dataone/auth.py index e0e637c..5501cd8 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -1,10 +1,12 @@ import json import os import re +import httpx import requests from authlib.jose import JsonWebKey, jwt + MAX_TOKEN_LEN = 16_384 _DEFAULT_SECRETS_PATH = "./client_secrets.json" diff --git a/uv.lock b/uv.lock index 5b49c7f..6bd822e 100644 --- a/uv.lock +++ b/uv.lock @@ -246,6 +246,7 @@ source = { editable = "." } dependencies = [ { name = "authlib" }, { name = "flask" }, + { name = "httpx" }, { name = "requests" }, { name = "werkzeug" }, ] @@ -275,6 +276,7 @@ requires-dist = [ { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.136.1" }, { name = "flask", specifier = ">=3.1.3" }, { name = "flask", marker = "extra == 'flask'", specifier = ">=3.1.3" }, + { name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", marker = "extra == 'fastapi'", specifier = ">=0.28.1" }, { name = "httpx", marker = "extra == 'starlette'", specifier = ">=0.28.1" }, { name = "requests", specifier = ">=2.33.1" }, From 9976d908d537f7d294bd956b08b81a6a87cf023a Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Tue, 12 May 2026 16:12:21 -0700 Subject: [PATCH 24/63] more ruff fixes --- tests/test_auth.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_auth.py b/tests/test_auth.py index 05f02fb..a17e90a 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,7 +1,7 @@ """Unit tests for auth.py helpers.""" import pytest -from dataone.auth import extract_orcid -from dataone.auth import AuthFactory + +from dataone.auth import AuthFactory, extract_orcid def test_extract_orcid_returns_https_uri_from_https_orcid_claim(): From 267d9fcace5477367c332f739e13939b1e3c2e2b Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Thu, 14 May 2026 14:18:00 -0700 Subject: [PATCH 25/63] bring exception handling helpers into lib --- src/dataone/auth.py | 153 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 124 insertions(+), 29 deletions(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 5501cd8..d51ca2a 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -1,11 +1,17 @@ import json import os import re -import httpx +import authlib.integrations.base_client.errors as base_client_errors +import httpx import requests from authlib.jose import JsonWebKey, jwt +from authlib.oauth2.rfc6749.errors import ( + OAuth2Error, +) +from requests import RequestException +### Params MAX_TOKEN_LEN = 16_384 _DEFAULT_SECRETS_PATH = "./client_secrets.json" @@ -14,9 +20,23 @@ ACCESS_MODE_READ_ONLY = "read_only" ACCESS_MODE_OPEN = "open" +_ORCID_HTTPS_PREFIX = "https://orcid.org/" +_ORCID_HTTP_PREFIX = "http://orcid.org/" + +### Exceptions + class MissingParameterError(Exception): """Raised when a required request parameter is missing.""" +class AuthError(Exception): + """Base exception for dataone-auth""" + pass + +class InsufficientScopeError(AuthError): + """Raised when the token is valid but doesn't have the right scope""" + pass + +### Helpers def load_client_secrets(filepath: str | None = None) -> dict: """Load client secrets from a JSON file. @@ -28,7 +48,8 @@ def load_client_secrets(filepath: str | None = None) -> dict: Returns: Parsed dict of client credentials. """ - # accept either explicit filepath argument or environment variable, with a default fallback + # accept either explicit filepath argument or environment variable, with a default + # fallback resolved = ( filepath or os.getenv("OIDC_CLIENT_SECRETS_FILE") @@ -50,16 +71,13 @@ def extract_token_from_header(auth_header: str): if token.count('.') != 2: return None - # caps the token length to prevent huge tokens from causing DoS issues in downstream processing. + # caps the token length to prevent huge tokens from causing DoS issues in downstream + # processing. if len(token) > MAX_TOKEN_LEN: return None return token -_ORCID_HTTPS_PREFIX = "https://orcid.org/" -_ORCID_HTTP_PREFIX = "http://orcid.org/" - -# leave this in as a helper def extract_orcid(claims: dict | None) -> str | None: """Extract a normalised ORCID iD URI from JWT claims. @@ -95,19 +113,20 @@ def extract_orcid(claims: dict | None) -> str | None: return _ORCID_HTTPS_PREFIX + bare -# probably remove def get_access_mode() -> str: """Get the current access mode from environment. Returns: - str: One of 'read_only', 'open', or 'authenticated'. Defaults to 'authenticated'. + str: One of 'read_only', 'open', or 'authenticated'. Defaults to + 'authenticated'. """ mode = os.getenv("ACCESS_MODE", "authenticated").lower() if mode not in (ACCESS_MODE_READ_ONLY, ACCESS_MODE_OPEN, ACCESS_MODE_AUTHENTICATED): - logger.warning(f"Invalid access mode '{mode}', falling back to '{ACCESS_MODE_AUTHENTICATED}'") return ACCESS_MODE_AUTHENTICATED return mode +### Factory + class AuthFactory: _registry = { @@ -155,13 +174,47 @@ def _setup_providers(self): client_kwargs={"scope": scope_request}, ) + ERROR_MAP = { + KeyError: ("Invalid token structure", 401), + TypeError: ("Invalid token structure", 401), + ValueError: ("OIDC provider configuration error", 500), + RequestException: ("Failed to fetch OIDC provider keys", 502), + InsufficientScopeError: ("Insufficient permissions", 403), + base_client_errors.OAuthError: ("Authorization failed", 401), + OAuth2Error: ("An OAuth2 error occurred", 401), + } + + def _resolve_error(self, exc: Exception): + """Logic to determine message and status from an exception.""" + # Check for specific Authlib errors (handle imports or strings) + for exc_type, (msg, code) in self.ERROR_MAP.items(): + # Parentheses let us wrap this logic across lines cleanly + is_match = ( + isinstance(exc, exc_type) if not isinstance(exc_type, str) + else type(exc).__name__ == exc_type + ) + + if is_match: + return msg, code + + return "Internal authentication error", 500 + + def error_handler(self, exc: Exception): + """This will be implemented by subclasses.""" + raise NotImplementedError + + def token_response(self, token: dict, message: str): + """This will be implemented by subclasses.""" + raise NotImplementedError + def get_jwks_keys(self): """Fetch and cache the JWKS signing keys from the OIDC provider. - These keys are used to validate JWT token signatures. Care must be taken to fetch - them only from trustworthy sources (via the OIDC provider's metadata endpoint over - HTTPS). The keys may change periodically, so the cache will be invalidated and keys - will be refetched on the next call after the application is restarted. + These keys are used to validate JWT token signatures. Care must be taken to + fetch them only from trustworthy sources (via the OIDC provider's metadata + endpoint over HTTPS). The keys may change periodically, so the cache will be + invalidated and keys will be refetched on the next call after the application + is restarted. Returns: authlib.jose.JsonWebKey: A ``JsonWebKeySet`` ready for ``jwt.decode``. @@ -194,7 +247,8 @@ def get_jwks_keys(self): def decode_and_validate_token(self, token_str: str): """Decode *and* full-validate a JWT against the OIDC provider's JWKS. - Validates signature, issuer (iss), audience (aud), and authorized-party (azp) claims. + Validates signature, issuer (iss), audience (aud), and authorized-party (azp) + claims. """ jwks = self.get_jwks_keys() @@ -237,25 +291,52 @@ def validate_and_extract_claims(self, token_str: str, required_scope: str = None token_scopes = claims.get("scope", "").split() if required_scope not in token_scopes: raise InsufficientScopeError( - f"Required: '{required_scope}'. Available: {[s for s in token_scopes]}" + f"Required: '{required_scope}'." + "Available: {[s for s in token_scopes]}" ) return claims def __getattr__(self, name): """ - Delegate all unknown attribute/method lookups to the underlying Authlib OAuth object. - This automatically exposes .register(), .init_app(), etc. + Delegate all unknown attribute/method lookups to the underlying Authlib OAut + object. This automatically exposes .register(), .init_app(), etc. """ return getattr(self.oauth, name) -# adapters +### Adapters class FastAPIAuthAdapter(BaseAuthAdapter): def _initialize_oauth(self): from authlib.integrations.starlette_client import OAuth + from fastapi.responses import JSONResponse + self._response_class = JSONResponse return OAuth() + def error_handler(self, exc: Exception): + msg, code = self._resolve_error(exc) + return self._response_class( + status_code=code, + content={ + "error": { + "message": msg, + "details": str(exc) + } + } + ) + + def token_response(self, token: dict, message: str = "Success"): + return self._response_class( + status_code=200, + content={ + "message": message, + "token": { + "access_token": token.get("access_token"), + "refresh_token": token.get("refresh_token"), + } + } + ) + async def get_jwks_keys(self): """Async override for fetching JWKS.""" if hasattr(self, '_cached_jwks'): @@ -300,7 +381,9 @@ async def decode_and_validate_token(self, token_str: str): claims.validate() return claims - async def validate_and_extract_claims(self, token_str: str, required_scope: str = None): + async def validate_and_extract_claims(self, + token_str: str, + required_scope: str = None): """Async override for claim extraction.""" claims = await self.decode_and_validate_token(token_str) @@ -308,7 +391,8 @@ async def validate_and_extract_claims(self, token_str: str, required_scope: str token_scopes = claims.get("scope", "").split() if required_scope not in token_scopes: raise InsufficientScopeError( - f"Required: '{required_scope}'. Available: {[s for s in token_scopes]}" + f"Required: '{required_scope}'." + "Available: {[s for s in token_scopes]}" ) return claims @@ -318,12 +402,23 @@ def _initialize_oauth(self): from authlib.integrations.flask_client import OAuth return OAuth() -# exceptions + def error_handler(self, exc: Exception): + from flask import jsonify + msg, code = self._resolve_error(exc) + return jsonify({ + "error": { + "message": msg, + "details": str(exc) + } + }), code + + def token_response(self, token: dict, message: str = "Success"): + from flask import jsonify + return jsonify({ + "message": message, + "token": { + "access_token": token.get("access_token"), + "refresh_token": token.get("refresh_token"), + } + }), 200 -class AuthError(Exception): - """Base exception for dataone-auth""" - pass - -class InsufficientScopeError(AuthError): - """Raised when the token is valid but doesn't have the right scope""" - pass \ No newline at end of file From d7b172bab411d85e69363805288436a3ab9eb052 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Thu, 14 May 2026 14:18:13 -0700 Subject: [PATCH 26/63] ruff fixes --- tests/test_auth.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/tests/test_auth.py b/tests/test_auth.py index a17e90a..8a9f134 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -5,13 +5,15 @@ def test_extract_orcid_returns_https_uri_from_https_orcid_claim(): - """Test that extract_orcid returns the canonical HTTPS URI when the orcid claim is already a full HTTPS URI.""" + """Test that extract_orcid returns the canonical HTTPS URI when the orcid claim is + already a full HTTPS URI.""" claims = {"orcid": "https://orcid.org/0000-0002-1825-0097"} assert extract_orcid(claims) == "https://orcid.org/0000-0002-1825-0097" def test_extract_orcid_normalises_http_orcid_claim_to_https(): - """Test that extract_orcid upgrades an http:// orcid claim URI to the canonical https:// URI.""" + """Test that extract_orcid upgrades an http:// orcid claim URI to the canonical + https:// URI.""" claims = {"orcid": "http://orcid.org/0000-0002-1825-0097"} assert extract_orcid(claims) == "https://orcid.org/0000-0002-1825-0097" @@ -23,7 +25,8 @@ def test_extract_orcid_normalises_bare_id_to_https_uri(): def test_extract_orcid_returns_none_for_none_input(): - """Test that extract_orcid returns None when called with None instead of a claims dict.""" + """Test that extract_orcid returns None when called with None instead of a + claims dict.""" assert extract_orcid(None) is None @@ -45,7 +48,9 @@ def test_factory_returns_flask_adapter(): from dataone.auth import FlaskAuthAdapter - adapter = AuthFactory.create_client("flask", secrets=MOCK_SECRETS, scopes=MOCK_SCOPES) + adapter = AuthFactory.create_client("flask", + secrets=MOCK_SECRETS, + scopes=MOCK_SCOPES) assert isinstance(adapter, FlaskAuthAdapter) assert adapter.secrets == MOCK_SECRETS @@ -56,7 +61,9 @@ def test_factory_returns_fastapi_adapter(): from dataone.auth import FastAPIAuthAdapter - adapter = AuthFactory.create_client("fastapi", secrets=MOCK_SECRETS, scopes=MOCK_SCOPES) + adapter = AuthFactory.create_client("fastapi", + secrets=MOCK_SECRETS, + scopes=MOCK_SCOPES) assert isinstance(adapter, FastAPIAuthAdapter) assert adapter.secrets == MOCK_SECRETS @@ -64,4 +71,6 @@ def test_factory_returns_fastapi_adapter(): def test_factory_raises_error_on_unknown_framework(): with pytest.raises(ValueError, match="Unsupported framework"): - AuthFactory.create_client("django", secrets=MOCK_SECRETS, scopes=MOCK_SCOPES) \ No newline at end of file + AuthFactory.create_client("django", + secrets=MOCK_SECRETS, + scopes=MOCK_SCOPES) \ No newline at end of file From e593bf3b244c7d7c18dc58d295a844661dddc14a Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Thu, 14 May 2026 14:45:08 -0700 Subject: [PATCH 27/63] update error map --- src/dataone/auth.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index d51ca2a..b807a6f 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -2,13 +2,13 @@ import os import re -import authlib.integrations.base_client.errors as base_client_errors import httpx import requests +from authlib.integrations.base_client.errors import OAuthError from authlib.jose import JsonWebKey, jwt -from authlib.oauth2.rfc6749.errors import ( - OAuth2Error, -) +from authlib.jose.errors import BadSignatureError, DecodeError, InvalidTokenError +from authlib.oauth2 import OAuth2Error +from authlib.oauth2.rfc6749.errors import InvalidClientError, InvalidGrantError from requests import RequestException ### Params @@ -175,13 +175,18 @@ def _setup_providers(self): ) ERROR_MAP = { + DecodeError: ("Token decoding failed", 401), + InvalidClientError: ("OIDC client authentication failed", 401), + InvalidTokenError: ("Token validation failed", 401), + InvalidGrantError: ("Invalid or expired refresh token", 401), + BadSignatureError: ("Token signature verification failed", 401), + OAuthError: ("Authorization failed", 401), + OAuth2Error: ("An OAuth2 error occurred", 401), KeyError: ("Invalid token structure", 401), TypeError: ("Invalid token structure", 401), + MissingParameterError: ("Missing required parameter", 400), ValueError: ("OIDC provider configuration error", 500), RequestException: ("Failed to fetch OIDC provider keys", 502), - InsufficientScopeError: ("Insufficient permissions", 403), - base_client_errors.OAuthError: ("Authorization failed", 401), - OAuth2Error: ("An OAuth2 error occurred", 401), } def _resolve_error(self, exc: Exception): From 5a1637f70da7c6ce3615e19cbe16692a6aa054c3 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Thu, 14 May 2026 15:17:25 -0700 Subject: [PATCH 28/63] add better error checking for token extraction --- src/dataone/auth.py | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index b807a6f..2de60f5 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -36,6 +36,10 @@ class InsufficientScopeError(AuthError): """Raised when the token is valid but doesn't have the right scope""" pass +class TokenExtractionError(ValueError): + """Raised when the Authorization header is missing or malformed.""" + pass + ### Helpers def load_client_secrets(filepath: str | None = None) -> dict: @@ -59,22 +63,28 @@ def load_client_secrets(filepath: str | None = None) -> dict: return json.load(f) def extract_token_from_header(auth_header: str): - """Extracts and safely bounds a Bearer token from an Authorization header.""" + """Extracts and validates a Bearer token. Raises ValueError on failure.""" - # check there is a token - if not auth_header or not auth_header.startswith("Bearer "): - return None + if not auth_header: + raise TokenExtractionError("Missing Authorization header") + + if not auth_header.startswith("Bearer "): + raise TokenExtractionError( + "Invalid Authorization header format. Expected 'Bearer '" + ) token = auth_header[7:].strip() + + if not token: + raise TokenExtractionError("Token is empty") - # make sure it looks like a JWT token + # Check JWT structure if token.count('.') != 2: - return None + raise TokenExtractionError("Token is malformed (invalid JWT structure)") - # caps the token length to prevent huge tokens from causing DoS issues in downstream - # processing. + # DoS protection if len(token) > MAX_TOKEN_LEN: - return None + raise TokenExtractionError("Token exceeds maximum allowed length") return token @@ -175,6 +185,7 @@ def _setup_providers(self): ) ERROR_MAP = { + TokenExtractionError: ("Invalid token or header", 401), DecodeError: ("Token decoding failed", 401), InvalidClientError: ("OIDC client authentication failed", 401), InvalidTokenError: ("Token validation failed", 401), From 4ea10b19f85263bfe26c03322651398d0802815d Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Thu, 14 May 2026 15:29:51 -0700 Subject: [PATCH 29/63] add login, refresh, auth methods for both adapters --- src/dataone/auth.py | 73 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 2de60f5..434c73a 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -313,6 +313,22 @@ def validate_and_extract_claims(self, token_str: str, required_scope: str = None return claims + def login(self, redirect_uri: str): + raise NotImplementedError + + def authorize(self): + raise NotImplementedError + + def refresh(self, request_json: dict): + + refresh_token = request_json.get("refresh_token") + if not refresh_token: + raise TokenExtractionError("Missing refresh_token in request body") + + scope = request_json.get("scope") + # Call the specific implementation's fetch method + return self._do_refresh(refresh_token, scope) + def __getattr__(self, name): """ Delegate all unknown attribute/method lookups to the underlying Authlib OAut @@ -412,6 +428,43 @@ async def validate_and_extract_claims(self, ) return claims + + async def login(self, redirect_uri: str): + """Returns a Starlette/FastAPI RedirectResponse.""" + # The Starlette client's authorize_redirect is async + return await self.dataone_oidc.authorize_redirect(redirect_uri) + + async def authorize(self): + """Exchanges code for token and returns a JSONResponse.""" + try: + # Must await the token exchange in FastAPI + token = await self.dataone_oidc.authorize_access_token() + return self.token_response(token) + except Exception as e: + return self.error_handler(e) + + async def refresh(self, request_json: dict): + """Logic to handle refresh token exchange.""" + refresh_token = request_json.get("refresh_token") + if not refresh_token: + # This triggers our mapped TokenExtractionError (401) + return self.error_handler(TokenExtractionError("Missing refresh_token")) + + scope = request_json.get("scope") + + try: + kwargs = { + "grant_type": "refresh_token", + "refresh_token": refresh_token + } + if scope: + kwargs["scope"] = scope + + # The Starlette fetch_access_token is async + new_tokens = await self.dataone_oidc.fetch_access_token(**kwargs) + return self.token_response(new_tokens, message="Token refresh successful") + except Exception as e: + return self.error_handler(e) class FlaskAuthAdapter(BaseAuthAdapter): def _initialize_oauth(self): @@ -438,3 +491,23 @@ def token_response(self, token: dict, message: str = "Success"): } }), 200 + def login(self, redirect_uri: str): + return self.dataone_oidc.authorize_redirect(redirect_uri) + + def authorize(self): + try: + token = self.dataone_oidc.authorize_access_token() + return self.token_response(token) + except Exception as e: + return self.error_handler(e) + + def _do_refresh(self, refresh_token, scope=None): + try: + kwargs = {"grant_type": "refresh_token", "refresh_token": refresh_token} + if scope: + kwargs["scope"] = scope + new_tokens = self.dataone_oidc.fetch_access_token(**kwargs) + return self.token_response(new_tokens) + except Exception as e: + return self.error_handler(e) + From 54441edf1e41d84e2162c402a3a934b4e5e57ce0 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Thu, 14 May 2026 15:44:14 -0700 Subject: [PATCH 30/63] add request to fastAPI calls --- src/dataone/auth.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 434c73a..48dddf9 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -313,10 +313,10 @@ def validate_and_extract_claims(self, token_str: str, required_scope: str = None return claims - def login(self, redirect_uri: str): + def login(self, redirect_uri: str, request=None): raise NotImplementedError - def authorize(self): + def authorize(self, request=None): raise NotImplementedError def refresh(self, request_json: dict): @@ -429,16 +429,16 @@ async def validate_and_extract_claims(self, return claims - async def login(self, redirect_uri: str): + async def login(self, request, redirect_uri: str): """Returns a Starlette/FastAPI RedirectResponse.""" # The Starlette client's authorize_redirect is async - return await self.dataone_oidc.authorize_redirect(redirect_uri) + return await self.dataone_oidc.authorize_redirect(request, redirect_uri) - async def authorize(self): + async def authorize(self, request): """Exchanges code for token and returns a JSONResponse.""" try: # Must await the token exchange in FastAPI - token = await self.dataone_oidc.authorize_access_token() + token = await self.dataone_oidc.authorize_access_token(request) return self.token_response(token) except Exception as e: return self.error_handler(e) From 6cd156477b2f094fcd75c9e80dda3cd89a370081 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Thu, 14 May 2026 15:58:11 -0700 Subject: [PATCH 31/63] add require_scope methods --- src/dataone/auth.py | 47 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 48dddf9..ea339f4 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -1,3 +1,4 @@ +import functools import json import os import re @@ -329,6 +330,9 @@ def refresh(self, request_json: dict): # Call the specific implementation's fetch method return self._do_refresh(refresh_token, scope) + def require_scope(self, required_scope: str): + raise NotImplementedError + def __getattr__(self, name): """ Delegate all unknown attribute/method lookups to the underlying Authlib OAut @@ -466,6 +470,29 @@ async def refresh(self, request_json: dict): except Exception as e: return self.error_handler(e) + def require_scope(self, required_scope: str): + """Returns a dependency for FastAPI's Depends().""" + async def dependency(request): + from fastapi import HTTPException + # Handle 'read_only' logic + if self.access_mode != "authenticated": + return None + + try: + auth_header = request.headers.get("Authorization") + token = extract_token_from_header(auth_header) + # This call is async in FastAPI + claims = await self.validate_and_extract_claims(token, required_scope) + return claims + except Exception as e: + # In FastAPI, we RAISE the error handler's result + error_res = self.error_handler(e) + raise HTTPException( + status_code=error_res.status_code, + detail=json.loads(error_res.body.decode())["error"] + ) + return dependency + class FlaskAuthAdapter(BaseAuthAdapter): def _initialize_oauth(self): from authlib.integrations.flask_client import OAuth @@ -511,3 +538,23 @@ def _do_refresh(self, refresh_token, scope=None): except Exception as e: return self.error_handler(e) + def require_scope(self, required_scope: str): + def decorator(f): + @functools.wraps(f) + def decorated(*args, **kwargs): + # Handle the 'read_only' logic inside the adapter + if self.access_mode != "authenticated": + return f(None, *args, **kwargs) + + try: + from flask import request + token = extract_token_from_header( + request.headers.get("Authorization")) + claims = self.validate_and_extract_claims(token, required_scope) + # Pass claims into the route + return f(claims, *args, **kwargs) + except Exception as e: + return self.error_handler(e) + return decorated + return decorator + From 546e806f5983c227aaa613d5d965aa1c2cb4704f Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Thu, 14 May 2026 16:03:58 -0700 Subject: [PATCH 32/63] need this imports --- src/dataone/auth.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index ea339f4..3248acc 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -472,8 +472,10 @@ async def refresh(self, request_json: dict): def require_scope(self, required_scope: str): """Returns a dependency for FastAPI's Depends().""" - async def dependency(request): + from fastapi import Request + async def dependency(request: Request): from fastapi import HTTPException + from .auth import extract_token_from_header # Handle 'read_only' logic if self.access_mode != "authenticated": return None From c52dc7341efec01047211ec12b5a3de297c694bf Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Thu, 14 May 2026 16:08:05 -0700 Subject: [PATCH 33/63] on startup, get access mode --- src/dataone/auth.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 3248acc..25cf41b 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -168,6 +168,7 @@ def __init__(self, secrets, scopes): self.scopes = scopes self.oauth = self._initialize_oauth() self._setup_providers() + self.access_mode = get_access_mode() def _initialize_oauth(self): raise NotImplementedError From 75e0690613eb6b8b0a5c8973d04ff0a0e70036f1 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 15 May 2026 12:26:52 -0700 Subject: [PATCH 34/63] add docs everywhere --- src/dataone/auth.py | 423 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 371 insertions(+), 52 deletions(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 25cf41b..618440c 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -26,32 +26,40 @@ ### Exceptions -class MissingParameterError(Exception): - """Raised when a required request parameter is missing.""" - class AuthError(Exception): - """Base exception for dataone-auth""" + """Base exception for dataone-auth.""" pass +class MissingParameterError(AuthError): + """Raised when a required request parameter is missing.""" + class InsufficientScopeError(AuthError): - """Raised when the token is valid but doesn't have the right scope""" + """Raised when the token is valid but doesn't have the right scope.""" pass -class TokenExtractionError(ValueError): +class TokenExtractionError(AuthError): """Raised when the Authorization header is missing or malformed.""" pass +class ConfigurationError(AuthError): + pass + ### Helpers def load_client_secrets(filepath: str | None = None) -> dict: """Load client secrets from a JSON file. Args: - filepath: Optional explicit path. Falls back to the - ``OIDC_CLIENT_SECRETS_FILE`` environment variable + filepath: Optional explicit path. Falls back to the ``OIDC_CLIENT_SECRETS_FILE`` + environment variable, then finally to the default path of + "./client_secrets.json" Returns: Parsed dict of client credentials. + + Raises: + ConfigurationError: If the secrets file cannot be found at the resolved path, + or if the file does not contain valid JSON. """ # accept either explicit filepath argument or environment variable, with a default # fallback @@ -60,14 +68,32 @@ def load_client_secrets(filepath: str | None = None) -> dict: or os.getenv("OIDC_CLIENT_SECRETS_FILE") or _DEFAULT_SECRETS_PATH ) - with open(resolved) as f: - return json.load(f) + try: + with open(resolved) as f: + return json.load(f) + except FileNotFoundError: + raise ConfigurationError(f"Could not find OIDC secrets file at {resolved}") + except json.JSONDecodeError: + raise ConfigurationError(f"OIDC secrets file at {resolved} is not valid JSON") def extract_token_from_header(auth_header: str): - """Extracts and validates a Bearer token. Raises ValueError on failure.""" + """Extracts and validates a Bearer token from an auth header string. + + Args: + auth_header: Auth header as a string (e.g., "Bearer "). + + Returns: + The extracted JWT token. + + Raises: + MissingParameterError: If no header is supplied. + TokenExtractionError: If the token is empty, malformed, or exceeds the allowed + length. + + """ if not auth_header: - raise TokenExtractionError("Missing Authorization header") + raise MissingParameterError("Missing Authorization header") if not auth_header.startswith("Bearer "): raise TokenExtractionError( @@ -139,6 +165,13 @@ def get_access_mode() -> str: ### Factory class AuthFactory: + """Factory for generating framework-specific authentication adapters. + + This factory uses a registry and dynamic imports to instantiate the correct + adapter (e.g., Flask or FastAPI) based on the running application. This pattern + ensures that a Flask application does not need to install FastAPI/Starlette + dependencies, and vice versa. + """ _registry = { "flask": "dataone.auth.FlaskAuthAdapter", @@ -148,6 +181,23 @@ class AuthFactory: @classmethod def create_client(cls, framework: str, secrets: dict, scopes: list): + """Creates and returns the appropriate authentication adapter. + + Args: + framework: A string identifying the target web framework (e.g., "flask", + "fastapi"). + secrets: A dictionary containing the OIDC client credentials, typically + loaded via `load_client_secrets()`. + scopes: A list of default OIDC scopes to request from the authorization + server (e.g., ["ogdc:admin"]). + + Returns: + BaseAuthAdapter: An instantiated, framework-specific adapter (such as + `FlaskAuthAdapter` or `FastAPIAuthAdapter`). + + Raises: + ValueError: If the framework string is not found in the registry. + """ import_path = cls._registry.get(framework.lower()) if not import_path: raise ValueError(f"Unsupported framework: {framework}") @@ -159,11 +209,46 @@ def create_client(cls, framework: str, secrets: dict, scopes: list): return AdapterClass(secrets=secrets, scopes=scopes) class BaseAuthAdapter: + """Base adapter for handling OIDC authentication. + + This class manages the core Authlib registry initialization, OIDC provider + setup, and access mode configuration. It is designed to be subclassed by + framework-specific adapters (e.g., FlaskAuthAdapter, FastAPIAuthAdapter) + that implement the actual request handling and dependency/decorator logic. + + Attributes: + DEFAULT_PROVIDER_NAME (str): The internal registry name for the OIDC provider. + DEFAULT_SCOPES (str): The standard base scopes requested during login. + access_mode (str): The current operating mode ('authenticated', 'read_only', + or 'open'), loaded during initialization. + """ DEFAULT_PROVIDER_NAME = "dataone_oidc" DEFAULT_SCOPES = "openid email profile" + ERROR_MAP = { + TokenExtractionError: ("Invalid token or header", 401), + DecodeError: ("Token decoding failed", 401), + InvalidClientError: ("OIDC client authentication failed", 401), + InvalidTokenError: ("Token validation failed", 401), + InvalidGrantError: ("Invalid or expired refresh token", 401), + BadSignatureError: ("Token signature verification failed", 401), + OAuthError: ("Authorization failed", 401), + OAuth2Error: ("An OAuth2 error occurred", 401), + KeyError: ("Invalid token structure", 401), + TypeError: ("Invalid token structure", 401), + MissingParameterError: ("Missing required parameter", 400), + ValueError: ("OIDC provider configuration error", 500), + RequestException: ("Failed to fetch OIDC provider keys", 502), + } + def __init__(self, secrets, scopes): + """Initializes the base authentication adapter. + + Args: + secrets: Dictionary of OIDC client credentials. + scopes: List of additional OIDC scopes to request. + """ self.secrets = secrets self.scopes = scopes self.oauth = self._initialize_oauth() @@ -171,9 +256,11 @@ def __init__(self, secrets, scopes): self.access_mode = get_access_mode() def _initialize_oauth(self): + """This is implemented by subclasses.""" raise NotImplementedError def _setup_providers(self): + """Registers the OIDC provider using loaded secrets and scopes.""" base_scopes = self.DEFAULT_SCOPES.split() scope_request = " ".join(dict.fromkeys(base_scopes + self.scopes)) @@ -186,24 +273,18 @@ def _setup_providers(self): client_kwargs={"scope": scope_request}, ) - ERROR_MAP = { - TokenExtractionError: ("Invalid token or header", 401), - DecodeError: ("Token decoding failed", 401), - InvalidClientError: ("OIDC client authentication failed", 401), - InvalidTokenError: ("Token validation failed", 401), - InvalidGrantError: ("Invalid or expired refresh token", 401), - BadSignatureError: ("Token signature verification failed", 401), - OAuthError: ("Authorization failed", 401), - OAuth2Error: ("An OAuth2 error occurred", 401), - KeyError: ("Invalid token structure", 401), - TypeError: ("Invalid token structure", 401), - MissingParameterError: ("Missing required parameter", 400), - ValueError: ("OIDC provider configuration error", 500), - RequestException: ("Failed to fetch OIDC provider keys", 502), - } - def _resolve_error(self, exc: Exception): - """Logic to determine message and status from an exception.""" + """Resolves an exception to an error message and HTTP status code. + + Evaluates the exception against ERROR_MAP, matching by direct class type + or class name (string) to avoid hard dependency imports. + + Args: + exc: The caught exception to be resolved. + + Returns: + A tuple containing the error message (str) and HTTP status code (int). + """ # Check for specific Authlib errors (handle imports or strings) for exc_type, (msg, code) in self.ERROR_MAP.items(): # Parentheses let us wrap this logic across lines cleanly @@ -218,11 +299,11 @@ def _resolve_error(self, exc: Exception): return "Internal authentication error", 500 def error_handler(self, exc: Exception): - """This will be implemented by subclasses.""" + """This is implemented by subclasses.""" raise NotImplementedError def token_response(self, token: dict, message: str): - """This will be implemented by subclasses.""" + """This is implemented by subclasses.""" raise NotImplementedError def get_jwks_keys(self): @@ -263,12 +344,17 @@ def get_jwks_keys(self): return self._cached_jwks def decode_and_validate_token(self, token_str: str): - """Decode *and* full-validate a JWT against the OIDC provider's JWKS. + """Decodes and validates a JWT using the provider's JWKS. - Validates signature, issuer (iss), audience (aud), and authorized-party (azp) - claims. - """ + Enforces signature validity as well as the exact issuer (iss), + audience (aud), and authorized party (azp) claims. + Args: + token_str: The raw JWT string to validate. + + Returns: + The validated token claims object. + """ jwks = self.get_jwks_keys() provider = getattr(self.oauth, self.DEFAULT_PROVIDER_NAME) @@ -316,22 +402,19 @@ def validate_and_extract_claims(self, token_str: str, required_scope: str = None return claims def login(self, redirect_uri: str, request=None): + """This is implemented by subclasses.""" raise NotImplementedError def authorize(self, request=None): + """This is implemented by subclasses.""" raise NotImplementedError def refresh(self, request_json: dict): - - refresh_token = request_json.get("refresh_token") - if not refresh_token: - raise TokenExtractionError("Missing refresh_token in request body") - - scope = request_json.get("scope") - # Call the specific implementation's fetch method - return self._do_refresh(refresh_token, scope) + """This is implemented by subclasses.""" + raise NotImplementedError def require_scope(self, required_scope: str): + """This is implemented by subclasses.""" raise NotImplementedError def __getattr__(self, name): @@ -345,12 +428,26 @@ def __getattr__(self, name): class FastAPIAuthAdapter(BaseAuthAdapter): def _initialize_oauth(self): + """Initializes the Starlette-based OAuth registry for FastAPI. + + Sets the internal response class to FastAPI's JSONResponse and returns + the instantiated Authlib OAuth object. + """ from authlib.integrations.starlette_client import OAuth from fastapi.responses import JSONResponse self._response_class = JSONResponse return OAuth() def error_handler(self, exc: Exception): + """Formats an exception into a FastAPI JSON response. + + Args: + exc: The exception caught during authentication or request processing. + + Returns: + A JSONResponse object containing the resolved HTTP status code + and formatted error payload. + """ msg, code = self._resolve_error(exc) return self._response_class( status_code=code, @@ -363,6 +460,18 @@ def error_handler(self, exc: Exception): ) def token_response(self, token: dict, message: str = "Success"): + """Formats successful token data into a FastAPI JSON response. + + Args: + token: A dictionary containing the token data (must include at least + 'access_token' and 'refresh_token' keys). + message: An optional success message to include in the response payload. + Defaults to "Success". + + Returns: + A JSONResponse object with a 200 status code and the standardized + token payload. + """ return self._response_class( status_code=200, content={ @@ -375,12 +484,24 @@ def token_response(self, token: dict, message: str = "Success"): ) async def get_jwks_keys(self): - """Async override for fetching JWKS.""" + """Asynchronously fetches and caches the OIDC provider's JWKS. + + Retrieves the provider metadata to find the `jwks_uri`, makes a non-blocking + HTTP request to fetch the keys using `httpx`, and caches the parsed key set + to prevent redundant network calls. + + Returns: + The parsed JsonWebKey set. + + Raises: + ValueError: If the provider metadata does not contain a 'jwks_uri'. + httpx.HTTPStatusError: If the network request to the `jwks_uri` fails. + """ if hasattr(self, '_cached_jwks'): return self._cached_jwks provider = getattr(self.oauth, self.DEFAULT_PROVIDER_NAME) - # Starlette requires await here + # FastAPI requires await here metadata = await provider.load_server_metadata() jwks_uri = metadata.get("jwks_uri") @@ -421,7 +542,18 @@ async def decode_and_validate_token(self, token_str: str): async def validate_and_extract_claims(self, token_str: str, required_scope: str = None): - """Async override for claim extraction.""" + """Asynchronously decodes and validates a JWT using the provider's JWKS. + + This overrides the base method to support Starlette/FastAPI's asynchronous + metadata and JWKS fetching. It enforces signature validity as well as exact + matching for issuer (iss), audience (aud), and authorized party (azp) claims. + + Args: + token_str: The raw JWT string to validate. + + Returns: + The validated token claims object. + """ claims = await self.decode_and_validate_token(token_str) if required_scope: @@ -435,12 +567,49 @@ async def validate_and_extract_claims(self, return claims async def login(self, request, redirect_uri: str): - """Returns a Starlette/FastAPI RedirectResponse.""" + """Asynchronously initiates the OIDC login flow. + + Uses the Starlette OAuth client to generate a redirect response that + sends the user to the authorization server. + + Args: + request: The incoming Starlette or FastAPI Request object. + redirect_uri: The callback URL where the authorization server will + redirect the user after authentication. + + Returns: + A Starlette RedirectResponse object pointing to the OIDC provider. + + Example: + @app.get("/login") + async def login(request: Request): + return await auth_adapter.login( + request=request, + redirect_uri=str(request.url_for("authorize")) + ) + """ # The Starlette client's authorize_redirect is async return await self.dataone_oidc.authorize_redirect(request, redirect_uri) async def authorize(self, request): - """Exchanges code for token and returns a JSONResponse.""" + """Asynchronously exchanges an authorization code for an access token. + + This method is designed to be used in the OIDC callback route. It + processes the incoming redirect from the authorization server, extracts + the code, and fetches the final tokens. + + Args: + request: The incoming FastAPI Request object containing the auth code. + + Returns: + A JSONResponse containing the extracted tokens on success, or a + formatted error response on failure. + + Example: + @app.get("/authorize") + async def authorize(request: Request): + return await auth_adapter.authorize(request=request) + """ try: # Must await the token exchange in FastAPI token = await self.dataone_oidc.authorize_access_token(request) @@ -449,7 +618,25 @@ async def authorize(self, request): return self.error_handler(e) async def refresh(self, request_json: dict): - """Logic to handle refresh token exchange.""" + """Asynchronously exchanges a refresh token for new access tokens. + + Overrides the synchronous base method to accommodate FastAPI's async + token fetching. + + Args: + request_json: A dictionary (typically the parsed JSON body of the + request) containing at least a 'refresh_token'. + + Returns: + A JSONResponse containing the new access and refresh tokens, or an + error response if the token is missing or invalid. + + Example: + @app.post("/refresh") + async def refresh(request: Request): + body = await request.json() + return await auth_adapter.refresh(body) + """ refresh_token = request_json.get("refresh_token") if not refresh_token: # This triggers our mapped TokenExtractionError (401) @@ -472,10 +659,38 @@ async def refresh(self, request_json: dict): return self.error_handler(e) def require_scope(self, required_scope: str): - """Returns a dependency for FastAPI's Depends().""" + """Creates a FastAPI dependency to enforce scope requirements on routes. + + This method returns an async function designed to be injected into FastAPI + endpoints using `Depends()`. It extracts the token, validates it against + the requested scope, and returns the claims. If the adapter's access mode + is not set to 'authenticated' (e.g., 'read_only'), validation is bypassed. + + Args: + required_scope: The specific OAuth scope required to access the route + (e.g., "read:data" or "write:admin"). + + Returns: + An asynchronous callable dependency that returns validated token claims. + + Raises: + fastapi.HTTPException: If token validation fails. The internal exception + is translated into a standard FastAPI HTTP error + using the adapter's error handler. + + Example: + from fastapi import Depends + + @app.get("/secure-data") + async def get_secure_data( + claims: dict = Depends(auth_adapter.require_scope("read:data")) + ): + return {"message": "Access granted", "user": claims.get("sub")} + """ from fastapi import Request async def dependency(request: Request): from fastapi import HTTPException + from .auth import extract_token_from_header # Handle 'read_only' logic if self.access_mode != "authenticated": @@ -498,10 +713,24 @@ async def dependency(request: Request): class FlaskAuthAdapter(BaseAuthAdapter): def _initialize_oauth(self): + """Initializes the Flask-based OAuth registry. + + Sets the internal response class to FastAPI's JSONResponse and returns + the instantiated Authlib OAuth object. + """ from authlib.integrations.flask_client import OAuth return OAuth() def error_handler(self, exc: Exception): + """Formats an exception into a Flask JSON response. + + Args: + exc: The exception caught during authentication or request processing. + + Returns: + A flask.Response object containing the resolved HTTP status code + and formatted error payload. + """ from flask import jsonify msg, code = self._resolve_error(exc) return jsonify({ @@ -512,6 +741,18 @@ def error_handler(self, exc: Exception): }), code def token_response(self, token: dict, message: str = "Success"): + """Formats successful token data into a Flask JSON response. + + Args: + token: A dictionary containing the token data (must include at least + 'access_token' and 'refresh_token' keys). + message: An optional success message to include in the response payload. + Defaults to "Success". + + Returns: + A flask.Response object with a 200 status code and the standardized + token payload. + """ from flask import jsonify return jsonify({ "message": message, @@ -522,26 +763,104 @@ def token_response(self, token: dict, message: str = "Success"): }), 200 def login(self, redirect_uri: str): + """Initiates the OIDC login flow for Flask. + + Uses the Flask Authlib client to generate a redirect response that + sends the user to the authorization server. + + Args: + redirect_uri: The callback URL where the authorization server will + redirect the user after authentication. + + Returns: + A Flask Response object (redirect) pointing to the OIDC provider. + + Example: + @app.route("/login") + def login(): + return auth_client.login( + redirect_uri=url_for("authorize", _external=True) + ) + """ return self.dataone_oidc.authorize_redirect(redirect_uri) def authorize(self): + """Exchanges an authorization code for an access token in Flask. + + This method should be called within the OIDC callback route. It + automatically handles the code exchange by accessing the global + Flask request object. + + Returns: + A Flask Response object (JSON) containing the tokens on success, + or a formatted error response on failure. + + Example: + @app.route("/authorize") + def authorize(): + return auth_client.authorize() + """ try: token = self.dataone_oidc.authorize_access_token() return self.token_response(token) except Exception as e: return self.error_handler(e) - def _do_refresh(self, refresh_token, scope=None): + def refresh(self, request_json: dict): + """Executes the synchronous token refresh request for Flask. + + Args: + request_json: A dictionary (the parsed JSON body) containing + at least a 'refresh_token'. + + Returns: + A Flask Response object (JSON) containing the new tokens or + an error response if the exchange fails. + + Example: + @app.route("/refresh", methods=["POST"]) + def refresh_route(): + return auth_adapter.refresh(request.get_json()) + """ + refresh_token = request_json.get("refresh_token") + if not refresh_token: + # We return the error handler result instead of raising + # to match the Flask return-style flow. + return self.error_handler(TokenExtractionError("Missing refresh_token")) + + scope = request_json.get("scope") try: kwargs = {"grant_type": "refresh_token", "refresh_token": refresh_token} if scope: kwargs["scope"] = scope + new_tokens = self.dataone_oidc.fetch_access_token(**kwargs) - return self.token_response(new_tokens) + return self.token_response(new_tokens, message="Token refresh successful") except Exception as e: return self.error_handler(e) def require_scope(self, required_scope: str): + """Creates a Flask decorator to enforce scope requirements on routes. + + This method returns a decorator that extracts the Bearer token from the + 'Authorization' header, validates it, and injects the resulting claims + into the decorated function as the first argument. If the adapter is in + 'read_only' or 'open' mode, validation is bypassed and 'None' is passed + for the claims. + + Args: + required_scope: The specific OAuth scope required to access the route + (e.g., "read:data"). + + Returns: + A decorator function that wraps a Flask route handler. + + Example: + @app.route("/secure-data") + @auth_adapter.require_scope("read:data") + def get_secure_data(claims): + return {"message": "Access granted", "user": claims.get("sub")} + """ def decorator(f): @functools.wraps(f) def decorated(*args, **kwargs): From 4d64c98bf0fe16c6587c753125a0e2be43a8357c Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 15 May 2026 13:04:28 -0700 Subject: [PATCH 35/63] make code a little drier, and prefix some internal methods with _ --- src/dataone/auth.py | 117 +++++++++++++++++++++----------------------- 1 file changed, 55 insertions(+), 62 deletions(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 618440c..a9cfdd3 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -162,6 +162,19 @@ def get_access_mode() -> str: return ACCESS_MODE_AUTHENTICATED return mode +def decode_claims(token_str: str, jwks: str, client_id: str, issuer: str): + claims = jwt.decode( + token_str, + jwks, + claims_options={ + "iss": {"essential": True, "value": issuer}, + "aud": {"essential": True, "value": client_id}, + "azp": {"essential": True, "value": client_id}, + }, + ) + claims.validate() + return claims + ### Factory class AuthFactory: @@ -298,15 +311,27 @@ def _resolve_error(self, exc: Exception): return "Internal authentication error", 500 - def error_handler(self, exc: Exception): + def _verify_scope(self, claims: dict, required_scope: str | None): + """Internal helper to check if the required scope exists in claims.""" + if not required_scope: + return + + token_scopes = claims.get("scope", "").split() + if required_scope not in token_scopes: + raise InsufficientScopeError( + f"Required: '{required_scope}'. " + f"Available: {token_scopes}" + ) + + def _error_handler(self, exc: Exception): """This is implemented by subclasses.""" raise NotImplementedError - def token_response(self, token: dict, message: str): + def _token_response(self, token: dict, message: str): """This is implemented by subclasses.""" raise NotImplementedError - def get_jwks_keys(self): + def _get_jwks_keys(self): """Fetch and cache the JWKS signing keys from the OIDC provider. These keys are used to validate JWT token signatures. Care must be taken to @@ -343,7 +368,7 @@ def get_jwks_keys(self): return self._cached_jwks - def decode_and_validate_token(self, token_str: str): + def _decode_and_validate_token(self, token_str: str): """Decodes and validates a JWT using the provider's JWKS. Enforces signature validity as well as the exact issuer (iss), @@ -355,7 +380,7 @@ def decode_and_validate_token(self, token_str: str): Returns: The validated token claims object. """ - jwks = self.get_jwks_keys() + jwks = self._get_jwks_keys() provider = getattr(self.oauth, self.DEFAULT_PROVIDER_NAME) metadata = provider.load_server_metadata() @@ -363,17 +388,7 @@ def decode_and_validate_token(self, token_str: str): client_id = self.secrets.get("client_id") - claims = jwt.decode( - token_str, - jwks, - claims_options={ - "iss": {"essential": True, "value": issuer}, - "aud": {"essential": True, "value": client_id}, - "azp": {"essential": True, "value": client_id}, - }, - ) - claims.validate() - return claims + return decode_claims(token_str, jwks, client_id, issuer) def validate_and_extract_claims(self, token_str: str, required_scope: str = None): """Validate a token string and optionally check required scope. @@ -389,15 +404,9 @@ def validate_and_extract_claims(self, token_str: str, required_scope: str = None Exception: JoseError from Authlib if token is invalid/expired. InsufficientScopeError: If the token lacks the required scope. """ - claims = self.decode_and_validate_token(token_str) + claims = self._decode_and_validate_token(token_str) - if required_scope: - token_scopes = claims.get("scope", "").split() - if required_scope not in token_scopes: - raise InsufficientScopeError( - f"Required: '{required_scope}'." - "Available: {[s for s in token_scopes]}" - ) + self._verify_scope(claims, required_scope) return claims @@ -438,7 +447,7 @@ def _initialize_oauth(self): self._response_class = JSONResponse return OAuth() - def error_handler(self, exc: Exception): + def _error_handler(self, exc: Exception): """Formats an exception into a FastAPI JSON response. Args: @@ -459,7 +468,7 @@ def error_handler(self, exc: Exception): } ) - def token_response(self, token: dict, message: str = "Success"): + def _token_response(self, token: dict, message: str = "Success"): """Formats successful token data into a FastAPI JSON response. Args: @@ -483,7 +492,7 @@ def token_response(self, token: dict, message: str = "Success"): } ) - async def get_jwks_keys(self): + async def _get_jwks_keys(self): """Asynchronously fetches and caches the OIDC provider's JWKS. Retrieves the provider metadata to find the `jwks_uri`, makes a non-blocking @@ -516,9 +525,9 @@ async def get_jwks_keys(self): self._cached_jwks = JsonWebKey.import_key_set(response.json()) return self._cached_jwks - async def decode_and_validate_token(self, token_str: str): + async def _decode_and_validate_token(self, token_str: str): """Async override for decoding.""" - jwks = await self.get_jwks_keys() + jwks = await self._get_jwks_keys() provider = getattr(self.oauth, self.DEFAULT_PROVIDER_NAME) # Starlette requires await here too @@ -527,17 +536,7 @@ async def decode_and_validate_token(self, token_str: str): client_id = self.secrets.get("client_id") - claims = jwt.decode( - token_str, - jwks, - claims_options={ - "iss": {"essential": True, "value": issuer}, - "aud": {"essential": True, "value": client_id}, - "azp": {"essential": True, "value": client_id}, - }, - ) - claims.validate() - return claims + return decode_claims(token_str, jwks, client_id, issuer) async def validate_and_extract_claims(self, token_str: str, @@ -554,15 +553,9 @@ async def validate_and_extract_claims(self, Returns: The validated token claims object. """ - claims = await self.decode_and_validate_token(token_str) + claims = await self._decode_and_validate_token(token_str) - if required_scope: - token_scopes = claims.get("scope", "").split() - if required_scope not in token_scopes: - raise InsufficientScopeError( - f"Required: '{required_scope}'." - "Available: {[s for s in token_scopes]}" - ) + self._verify_scope(claims, required_scope) return claims @@ -613,9 +606,9 @@ async def authorize(request: Request): try: # Must await the token exchange in FastAPI token = await self.dataone_oidc.authorize_access_token(request) - return self.token_response(token) + return self._token_response(token) except Exception as e: - return self.error_handler(e) + return self._error_handler(e) async def refresh(self, request_json: dict): """Asynchronously exchanges a refresh token for new access tokens. @@ -640,7 +633,7 @@ async def refresh(request: Request): refresh_token = request_json.get("refresh_token") if not refresh_token: # This triggers our mapped TokenExtractionError (401) - return self.error_handler(TokenExtractionError("Missing refresh_token")) + return self._error_handler(TokenExtractionError("Missing refresh_token")) scope = request_json.get("scope") @@ -654,9 +647,9 @@ async def refresh(request: Request): # The Starlette fetch_access_token is async new_tokens = await self.dataone_oidc.fetch_access_token(**kwargs) - return self.token_response(new_tokens, message="Token refresh successful") + return self._token_response(new_tokens, message="Token refresh successful") except Exception as e: - return self.error_handler(e) + return self._error_handler(e) def require_scope(self, required_scope: str): """Creates a FastAPI dependency to enforce scope requirements on routes. @@ -704,7 +697,7 @@ async def dependency(request: Request): return claims except Exception as e: # In FastAPI, we RAISE the error handler's result - error_res = self.error_handler(e) + error_res = self._error_handler(e) raise HTTPException( status_code=error_res.status_code, detail=json.loads(error_res.body.decode())["error"] @@ -721,7 +714,7 @@ def _initialize_oauth(self): from authlib.integrations.flask_client import OAuth return OAuth() - def error_handler(self, exc: Exception): + def _error_handler(self, exc: Exception): """Formats an exception into a Flask JSON response. Args: @@ -740,7 +733,7 @@ def error_handler(self, exc: Exception): } }), code - def token_response(self, token: dict, message: str = "Success"): + def _token_response(self, token: dict, message: str = "Success"): """Formats successful token data into a Flask JSON response. Args: @@ -802,9 +795,9 @@ def authorize(): """ try: token = self.dataone_oidc.authorize_access_token() - return self.token_response(token) + return self._token_response(token) except Exception as e: - return self.error_handler(e) + return self._error_handler(e) def refresh(self, request_json: dict): """Executes the synchronous token refresh request for Flask. @@ -826,7 +819,7 @@ def refresh_route(): if not refresh_token: # We return the error handler result instead of raising # to match the Flask return-style flow. - return self.error_handler(TokenExtractionError("Missing refresh_token")) + return self._error_handler(TokenExtractionError("Missing refresh_token")) scope = request_json.get("scope") try: @@ -835,9 +828,9 @@ def refresh_route(): kwargs["scope"] = scope new_tokens = self.dataone_oidc.fetch_access_token(**kwargs) - return self.token_response(new_tokens, message="Token refresh successful") + return self._token_response(new_tokens, message="Token refresh successful") except Exception as e: - return self.error_handler(e) + return self._error_handler(e) def require_scope(self, required_scope: str): """Creates a Flask decorator to enforce scope requirements on routes. @@ -876,7 +869,7 @@ def decorated(*args, **kwargs): # Pass claims into the route return f(claims, *args, **kwargs) except Exception as e: - return self.error_handler(e) + return self._error_handler(e) return decorated return decorator From 7d9036feae57831f98a136e34d29e8b4144b26d2 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 15 May 2026 13:10:57 -0700 Subject: [PATCH 36/63] add token structure and claims decoding tests --- tests/test_auth.py | 87 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/tests/test_auth.py b/tests/test_auth.py index 8a9f134..572d679 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,7 +1,10 @@ """Unit tests for auth.py helpers.""" import pytest -from dataone.auth import AuthFactory, extract_orcid +from dataone.auth import ( + AuthFactory, extract_orcid, extract_token_from_header, MissingParameterError, + TokenExtractionError, decode_claims +) def test_extract_orcid_returns_https_uri_from_https_orcid_claim(): @@ -34,6 +37,88 @@ def test_extract_orcid_returns_none_for_empty_claims(): """Test that extract_orcid returns None when called with an empty claims dict.""" assert extract_orcid({}) is None +def test_extract_token_success(): + """Test standard valid Bearer token extraction.""" + token = "header.payload.signature" + auth_header = f"Bearer {token}" + assert extract_token_from_header(auth_header) == token + +def test_extract_token_missing_header(): + """Test error when header is None or empty string.""" + with pytest.raises(MissingParameterError, match="Missing Authorization header"): + extract_token_from_header("") + +def test_extract_token_invalid_format(): + """Test error when 'Bearer ' prefix is missing.""" + with pytest.raises(TokenExtractionError, match="Invalid Authorization header format"): + extract_token_from_header("Token abc.def.ghi") + +def test_extract_token_empty_after_prefix(): + """Test error when header is just 'Bearer ' with no content.""" + with pytest.raises(TokenExtractionError, match="Token is empty"): + extract_token_from_header("Bearer ") + +def test_extract_token_malformed_jwt(): + """Test error when token doesn't have 2 dots.""" + with pytest.raises(TokenExtractionError, match="Token is malformed"): + extract_token_from_header("Bearer not-a-jwt") + +def test_extract_token_too_long(): + """Test DoS protection for oversized tokens.""" + long_token = "a.b." + ("c" * 20000) # Exceeds default 16,384 + with pytest.raises(TokenExtractionError, match="Token exceeds maximum allowed length"): + extract_token_from_header(f"Bearer {long_token}") + + +import pytest +from authlib.jose import jwt, JsonWebKey + +def test_decode_claims_success(): + # generate a simple RSA key for testing + key = JsonWebKey.generate_key('RSA', 2048, is_private=True) + public_jwks = JsonWebKey.import_key_set([key.as_dict(is_private=False)]) + + # setup mock claims/headers + header = {'alg': 'RS256', 'kid': key.as_dict().get('kid')} + payload = { + "iss": "https://auth.example.com", + "aud": "my_client_id", + "azp": "my_client_id", + "sub": "12345", + "scope": "openid profile" + } + + # create a signed token + token = jwt.encode(header, payload, key).decode('utf-8') + + # test + result = decode_claims( + token_str=token, + jwks=public_jwks, + client_id="my_client_id", + issuer="https://auth.example.com" + ) + + assert result['sub'] == "12345" + assert result['iss'] == "https://auth.example.com" + +def test_decode_claims_invalid_issuer(): + key = JsonWebKey.generate_key('RSA', 2048, is_private=True) + public_jwks = JsonWebKey.import_key_set([key.as_dict(is_private=False)]) + + # token has 'wrong-issuer' + payload = { + "iss": "wrong-issuer", + "aud": "my_client_id", + "azp": "my_client_id" + } + token = jwt.encode({'alg': 'RS256'}, payload, key).decode('utf-8') + + # this should raise an error because the 'value' doesn't match the claims_options + from authlib.jose.errors import InvalidClaimError + with pytest.raises(InvalidClaimError): + decode_claims(token, public_jwks, "my_client_id", "https://auth.example.com") + MOCK_SECRETS = { "client_id": "test client", "client_secret": "a string", From 7607771f80f6ef60ec48b6604187566c1848b360 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 15 May 2026 13:40:07 -0700 Subject: [PATCH 37/63] convert from jose to joserfc --- src/dataone/auth.py | 76 ++++++++++++++++++++++++++++----------------- tests/test_auth.py | 61 +++++++++++++++++++++++------------- 2 files changed, 87 insertions(+), 50 deletions(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index a9cfdd3..b0e6730 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -6,10 +6,11 @@ import httpx import requests from authlib.integrations.base_client.errors import OAuthError -from authlib.jose import JsonWebKey, jwt -from authlib.jose.errors import BadSignatureError, DecodeError, InvalidTokenError from authlib.oauth2 import OAuth2Error from authlib.oauth2.rfc6749.errors import InvalidClientError, InvalidGrantError +from joserfc import jwt +from joserfc.errors import JoseError +from joserfc.jwk import KeySet from requests import RequestException ### Params @@ -41,6 +42,10 @@ class TokenExtractionError(AuthError): """Raised when the Authorization header is missing or malformed.""" pass +class InvalidTokenError(AuthError): + """Raised when claims like iss or aud do not match expectations.""" + pass + class ConfigurationError(AuthError): pass @@ -162,17 +167,37 @@ def get_access_mode() -> str: return ACCESS_MODE_AUTHENTICATED return mode -def decode_claims(token_str: str, jwks: str, client_id: str, issuer: str): - claims = jwt.decode( - token_str, - jwks, - claims_options={ - "iss": {"essential": True, "value": issuer}, - "aud": {"essential": True, "value": client_id}, - "azp": {"essential": True, "value": client_id}, - }, - ) - claims.validate() + +def decode_claims(token_str, jwks, client_id, issuer): + """Decodes and validates a JWT using joserfc. + + Args: + token_str: The raw encoded JWT string. + jwks: The KeySet object returned by _get_jwks_keys. + client_id: The expected audience (aud) and authorized party (azp). + issuer: The expected issuer (iss) URI of the token. + + Returns: + The validated claims object. + + Raises: + ValueError: If the issuer, audience, or azp claims do not match + the expected values. + """ + token = jwt.decode(token_str, jwks) + + # standard joserfc validation (checks exp, nbf, etc.) + claims = token.claims + registry = jwt.JWTClaimsRegistry() + registry.validate(claims) + + if claims.get("iss") != issuer: + raise InvalidTokenError("Invalid issuer") + if claims.get("aud") != client_id: + raise InvalidTokenError("Invalid audience") + if claims.get("azp") and claims.get("azp") != client_id: + raise InvalidTokenError("Invalid authorized party (azp)") + return claims ### Factory @@ -241,11 +266,10 @@ class BaseAuthAdapter: ERROR_MAP = { TokenExtractionError: ("Invalid token or header", 401), - DecodeError: ("Token decoding failed", 401), + JoseError: ("Token decoding or signature verification failed", 401), # <- New + InvalidTokenError: ("Token validation failed", 401), # <- Now your custom error InvalidClientError: ("OIDC client authentication failed", 401), - InvalidTokenError: ("Token validation failed", 401), InvalidGrantError: ("Invalid or expired refresh token", 401), - BadSignatureError: ("Token signature verification failed", 401), OAuthError: ("Authorization failed", 401), OAuth2Error: ("An OAuth2 error occurred", 401), KeyError: ("Invalid token structure", 401), @@ -358,14 +382,12 @@ def _get_jwks_keys(self): if not jwks_uri: raise ValueError("OIDC provider metadata missing 'jwks_uri'") - jwks_uri = metadata.get("jwks_uri") - if not jwks_uri: - raise ValueError("OIDC provider metadata does not contain 'jwks_uri'") - response = requests.get(jwks_uri, timeout=10) response.raise_for_status() - self._cached_jwks = JsonWebKey.import_key_set(response.json()) - + + # joserfc uses KeySet.import_key_set + self._cached_jwks = KeySet.import_key_set(response.json()) + return self._cached_jwks def _decode_and_validate_token(self, token_str: str): @@ -507,22 +529,20 @@ async def _get_jwks_keys(self): httpx.HTTPStatusError: If the network request to the `jwks_uri` fails. """ if hasattr(self, '_cached_jwks'): - return self._cached_jwks + return self._cached_jwks provider = getattr(self.oauth, self.DEFAULT_PROVIDER_NAME) - # FastAPI requires await here metadata = await provider.load_server_metadata() - + jwks_uri = metadata.get("jwks_uri") if not jwks_uri: raise ValueError("OIDC provider metadata missing 'jwks_uri'") - - # Non-blocking HTTP request + async with httpx.AsyncClient() as client: response = await client.get(jwks_uri, timeout=10) response.raise_for_status() - self._cached_jwks = JsonWebKey.import_key_set(response.json()) + self._cached_jwks = KeySet.import_key_set(response.json()) return self._cached_jwks async def _decode_and_validate_token(self, token_str: str): diff --git a/tests/test_auth.py b/tests/test_auth.py index 572d679..e832271 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,9 +1,16 @@ """Unit tests for auth.py helpers.""" import pytest +from joserfc import jwt +from joserfc.jwk import KeySet, RSAKey from dataone.auth import ( - AuthFactory, extract_orcid, extract_token_from_header, MissingParameterError, - TokenExtractionError, decode_claims + AuthFactory, + InvalidTokenError, + MissingParameterError, + TokenExtractionError, + decode_claims, + extract_orcid, + extract_token_from_header, ) @@ -50,7 +57,8 @@ def test_extract_token_missing_header(): def test_extract_token_invalid_format(): """Test error when 'Bearer ' prefix is missing.""" - with pytest.raises(TokenExtractionError, match="Invalid Authorization header format"): + with pytest.raises(TokenExtractionError, + match="Invalid Authorization header format"): extract_token_from_header("Token abc.def.ghi") def test_extract_token_empty_after_prefix(): @@ -66,20 +74,24 @@ def test_extract_token_malformed_jwt(): def test_extract_token_too_long(): """Test DoS protection for oversized tokens.""" long_token = "a.b." + ("c" * 20000) # Exceeds default 16,384 - with pytest.raises(TokenExtractionError, match="Token exceeds maximum allowed length"): + with pytest.raises(TokenExtractionError, + match="Token exceeds maximum allowed length"): extract_token_from_header(f"Bearer {long_token}") - -import pytest -from authlib.jose import jwt, JsonWebKey - def test_decode_claims_success(): - # generate a simple RSA key for testing - key = JsonWebKey.generate_key('RSA', 2048, is_private=True) - public_jwks = JsonWebKey.import_key_set([key.as_dict(is_private=False)]) + # generate rsa key + raw_key = RSAKey.generate_key(2048) + + # export to dict and strictly set a string 'kid' + private_jwk = raw_key.as_dict(is_private=True) + private_jwk['kid'] = 'test-key-id-1' + + # re-import the key so it officially has the kid, and create the public JWKS + key = RSAKey.import_key(private_jwk) + public_jwk = KeySet.import_key_set({"keys": [key.as_dict(is_private=False)]}) # setup mock claims/headers - header = {'alg': 'RS256', 'kid': key.as_dict().get('kid')} + header = {'alg': 'RS256', 'kid': 'test-key-id-1'} payload = { "iss": "https://auth.example.com", "aud": "my_client_id", @@ -89,12 +101,12 @@ def test_decode_claims_success(): } # create a signed token - token = jwt.encode(header, payload, key).decode('utf-8') + token = jwt.encode(header, payload, key) # test result = decode_claims( token_str=token, - jwks=public_jwks, + jwks=public_jwk, client_id="my_client_id", issuer="https://auth.example.com" ) @@ -102,23 +114,28 @@ def test_decode_claims_success(): assert result['sub'] == "12345" assert result['iss'] == "https://auth.example.com" + def test_decode_claims_invalid_issuer(): - key = JsonWebKey.generate_key('RSA', 2048, is_private=True) - public_jwks = JsonWebKey.import_key_set([key.as_dict(is_private=False)]) + raw_key = RSAKey.generate_key(2048) + + private_jwk = raw_key.as_dict(is_private=True) + private_jwk['kid'] = 'test-key-id-2' + + key = RSAKey.import_key(private_jwk) + public_jwk = KeySet.import_key_set({"keys": [key.as_dict(is_private=False)]}) # token has 'wrong-issuer' + header = {'alg': 'RS256', 'kid': 'test-key-id-2'} payload = { "iss": "wrong-issuer", "aud": "my_client_id", "azp": "my_client_id" } - token = jwt.encode({'alg': 'RS256'}, payload, key).decode('utf-8') - - # this should raise an error because the 'value' doesn't match the claims_options - from authlib.jose.errors import InvalidClaimError - with pytest.raises(InvalidClaimError): - decode_claims(token, public_jwks, "my_client_id", "https://auth.example.com") + token = jwt.encode(header, payload, key) + # should raise InvalidTokenError + with pytest.raises(InvalidTokenError, match="Invalid issuer"): + decode_claims(token, public_jwk, "my_client_id", "https://auth.example.com") MOCK_SECRETS = { "client_id": "test client", "client_secret": "a string", From a1a7817932c6667f43a5c86a8d501319fc97fe8f Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 15 May 2026 13:40:20 -0700 Subject: [PATCH 38/63] update deps --- pyproject.toml | 1 + uv.lock | 2 ++ 2 files changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index d81b525..562042a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "authlib>=1.7.2", "flask>=3.1.3", "httpx>=0.28.1", + "joserfc>=1.6.5", "requests>=2.33.1", "werkzeug>=3.1.8", ] diff --git a/uv.lock b/uv.lock index 6bd822e..fe2adfe 100644 --- a/uv.lock +++ b/uv.lock @@ -247,6 +247,7 @@ dependencies = [ { name = "authlib" }, { name = "flask" }, { name = "httpx" }, + { name = "joserfc" }, { name = "requests" }, { name = "werkzeug" }, ] @@ -279,6 +280,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", marker = "extra == 'fastapi'", specifier = ">=0.28.1" }, { name = "httpx", marker = "extra == 'starlette'", specifier = ">=0.28.1" }, + { name = "joserfc", specifier = ">=1.6.5" }, { name = "requests", specifier = ">=2.33.1" }, { name = "starlette", marker = "extra == 'fastapi'", specifier = ">=1.0.0" }, { name = "werkzeug", specifier = ">=3.1.8" }, From dfe6465d287b4350bc39b8199f102ae944c63f68 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 15 May 2026 14:22:31 -0700 Subject: [PATCH 39/63] update docs --- README.md | 110 +++++++++++++- docs/app-code.md | 372 ----------------------------------------------- 2 files changed, 105 insertions(+), 377 deletions(-) delete mode 100644 docs/app-code.md diff --git a/README.md b/README.md index e00991d..6c17ef9 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ - Contact us: support@dataone.org - [DataONE discussions](https://github.com/DataONEorg/dataone/discussions) -*Product overview goes here.* Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. +The `dataone-auth` module provides a framework-agnostic OpenID Connect (OIDC) authentication adapter for Python applications. Using `joserfc` and `Authlib`, its core purpose is to handle JSON Web Token (JWT) validation so that DataONE Python applications can seamlessly integrate OIDC authentication, regardless of what framework that application uses. Currently, `dataone-auth` supports both Flask and FastAPI. To integrate `dataone-auth` into an existing app, the application only needs to create an auth client (`create_client`), use the `login`, `authorize`, `refresh` methods on the corresponding endpoints, and utilize the `require_scope` helper either as a decorator or `Depends` function to protect secure endpoints. For more detail on usage, see the examples below. DataONE creates open source, community projects. We [welcome contributions](./CONTRIBUTING.md) in many forms, including code, graphics, documentation, bug reports, testing, etc. Use the [DataONE discussions](https://github.com/DataONEorg/dataone/discussions) to discuss these contributions with us. @@ -37,15 +37,115 @@ To run the code formatter and linter, use Ruff: - `uv run ruff check .` -## Usage Example +## Usage Examples -To view more details about the Public API - see interface documentation +### Flask + +Below is a minimal example for a Flask application. For the Flask implementation, applying `ProxyFix` is recommended to ensure correct redirect URIs when the app is running behind a reverse proxy or load balancer. Following standard Flask extension patterns, the `auth_client` must be explicitly bound to the application using `init_app()`. Once initialized, protect any endpoint by stacking the `@auth_client.require_scope(...)` decorator below the route definition. This automatically intercepts the Bearer token, validates the OIDC claims against the provider's JWKS, and injects the resulting claims dictionary into the view function. ```python -import dataone.auth +import json +import os +from flask import Flask, jsonify, request, url_for +from werkzeug.middleware.proxy_fix import ProxyFix +from dataone.auth import AuthFactory, load_client_secrets + +# --- Constants & Logging --- +ACCESS_MODE_AUTHENTICATED = "authenticated" +scopes = ["ogdc:admin"] + +# --- App Initialization --- +app = Flask(__name__) +app.config.update({"SECRET_KEY": os.getenv("FLASK_SECRET_KEY", os.urandom(32).hex())}) + +if not isinstance(app.wsgi_app, ProxyFix): + app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1) + +# --- Auth Setup --- +secrets = load_client_secrets() +auth_client = AuthFactory.create_client("flask", secrets, scopes) +auth_client.init_app(app) +app.extensions['dataone_auth'] = auth_client + +# --- Routes --- +@app.route("/login") +def login(): + return auth_client.login(redirect_uri=url_for("authorize", _external=True)) + +@app.route("/authorize") +def authorize(): + return auth_client.authorize() + +@app.route("/refresh", methods=["POST"]) +def refresh_token(): + return auth_client.refresh(request_json=request.get_json(silent=True)) + +@app.route("/profile", methods=["GET"]) +@auth_client.require_scope("ogdc:admin") +def profile(claims): + """Protected resource endpoint requiring 'ogdc:admin' scope.""" + return jsonify({ + "message": f"Authorization succeeded, {claims.get('name', 'User')}", + "claims": claims # The claims object is already a dictionary! + }), 200 + +# --- Execution --- +if __name__ == "__main__": + app.run(host="0.0.0.0", port=int("4000"), debug=True) +``` -# Example code here... +### FastAPI +Below is a minimal example for a FastAPI application. Unlike Flask, FastAPI doesn't require an `init_app` step; the `auth_client` is ready to use immediately upon creation. Note that `SessionMiddleware` must be added to the app to handle the OIDC state and nonce during the browser-based login and authorization flow. For the API endpoints, the heavy lifting happens within the `Depends(auth_client.require_scope(...))` dependency, which automatically intercepts the Bearer token, validates the OIDC claims against the provider's JWKS, and injects the ready-to-use claims dictionary into the route handler. + +```python +import os +from fastapi import Depends, FastAPI, Request +from fastapi.security import HTTPBearer +from starlette.middleware.sessions import SessionMiddleware +from dataone.auth import AuthFactory, load_client_secrets + +# --- Constants & Logging --- +ACCESS_MODE_AUTHENTICATED = "authenticated" +scopes = ["ogdc:admin"] + +# --- App Initialization --- +app = FastAPI(title="DataONE OIDC API") +app.add_middleware( + SessionMiddleware, + secret_key=os.getenv("SECRET_KEY", os.urandom(32).hex()) +) + +# --- Auth Setup --- +secrets = load_client_secrets() +auth_client = AuthFactory.create_client("fastapi", secrets, scopes) + +security = HTTPBearer() +# --- Routes --- +@app.get("/login") +async def login(request: Request): + return await auth_client.login(request, redirect_uri=str(request.url_for("authorize"))) + +@app.get("/authorize") +async def authorize(request: Request): + return await auth_client.authorize(request) + +@app.post("/refresh") +async def refresh(request: Request): + return await auth_client.refresh(await request.json()) + +@app.get("/profile") +async def profile(claims: dict = Depends(auth_client.require_scope("ogdc:admin"))): + """Protected resource endpoint.""" + return { + "message": f"Authorization succeeded, {claims.get('name', 'User')}", + "claims": claims + } + +# --- Execution --- +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=4000, proxy_headers=True, forwarded_allow_ips="*") ``` ## License diff --git a/docs/app-code.md b/docs/app-code.md deleted file mode 100644 index badcb58..0000000 --- a/docs/app-code.md +++ /dev/null @@ -1,372 +0,0 @@ -# Changes to application code - -## Flask - -### Initialize client - - -``` -from werkzeug.middleware.proxy_fix import ProxyFix -from flask import current_app, g -# Assuming the user imports your factory -from dataone.factory import AuthFactory -from dataone.utils import load_client_secrets - -def init_oauth(app) -> bool: - """Initialise the OAuth client and register the OIDC provider.""" - - mode = get_access_mode() - if mode != ACCESS_MODE_AUTHENTICATED: - logger.warning("Access mode '%s': skipping OAuth initialisation.", mode) - return True - - try: - vb_secrets = load_client_secrets() - except (FileNotFoundError, json.JSONDecodeError) as exc: - logger.warning("Could not load client secrets (%s). Auth unavailable.", exc) - return False - - if not isinstance(app.wsgi_app, ProxyFix): - app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1) - - vb_scopes = [SCOPE_ADMIN, SCOPE_CONTRIBUTOR, SCOPE_USER] - - auth_client = AuthFactory.create_client("flask", vb_secrets, vb_scopes) - - auth_client.init_app(app) - - # attach to app context so Flask routes can access it later - app.extensions['dataone_auth'] = auth_client - - logger.info("OAuth client initialised.") - return True -``` - -### Protect endpoints with decorator - -``` -def require_token(methods=None, required_scope=None): - def decorator(f): - @functools.wraps(f) - def decorated(*args, **kwargs): - mode = get_access_mode() - if mode != ACCESS_MODE_AUTHENTICATED: - return f(None, *args, **kwargs) - - if methods is not None and request.method not in methods: - return f(None, *args, **kwargs) - - # Framework specific: Extract the token - auth_header = request.headers.get("Authorization", "") - if auth_header.startswith("Bearer "): - token = auth_header[7:] - - # caps the token length to prevent huge tokens from causing DoS issues in downstream processing. - if len(token) > MAX_TOKEN_LEN: - return None # triggers 401 - - adapter = current_app.extensions['dataone_auth'] - - try: - claims = adapter.validate_and_extract_claims(token_str, required_scope) - except InsufficientScopeError as e: - # Framework specific: Return 403 Forbidden - return jsonify({"error": str(e)}), 403 - except Exception as e: - # Framework specific: Return 401 Unauthorized - return jsonify({"error": f"Invalid token: {str(e)}"}), 401 - - g.token_claims = claims - return f(claims, *args, **kwargs) - - return decorated - return decorator - -``` - -``` -def require_scope(required_scope: str, methods=None): - def decorator(f): - @functools.wraps(f) - def decorated(*args, **kwargs): - mode = get_access_mode() - - # In read_only or open mode, skip auth entirely - if mode != ACCESS_MODE_AUTHENTICATED: - logger.warning("Access mode '%s': skipping scope validation", mode) - # Store None in g for consistency - g.token_claims = None - return f(*args, **kwargs) - - # If methods are specified, only enforce auth for those methods - if methods is not None and request.method not in methods: - # No auth required for this method; store None as claims - g.token_claims = None - return f(*args, **kwargs) - - adapter = current_app.extensions['dataone_auth'] - - claims, error = adapter.validate_and_extract_claims(required_scope=required_scope) - if error: - return error - - g.token_claims = claims - # Pass claims as keyword argument for explicit access in handlers - kwargs['claims'] = claims - return f(*args, **kwargs) - - return decorated - - return decorator -``` - -### API Endpoints - -**login** - -``` -@auth_bp.route("/login", methods=["GET"]) -def login(): - """Initiate the OIDC login flow. - - Sends the user to the provider's login page. After successful - authentication the provider redirects back to the ``/authorize`` - callback. - - Args: - (None) - - Returns: - 302 redirect to the provider's authorization endpoint. - 401/500 JSON error response if login fails. - 403 JSON response if authentication is disabled for the current access mode. - - """ - mode = get_access_mode() - if mode != ACCESS_MODE_AUTHENTICATED: - return _auth_error_response(f"Authentication is disabled in '{mode}' mode.", 403) - - adapter = current_app.extensions['dataone_auth'] - oidc_client = adapter.vegbank_oidc # maybe get this dynamically - - try: - return adapter.authorize_redirect(url_for("main.auth.authorize", _external=True)) - except (OAuthError, RequestException) as exc: - logger.warning("OIDC authorize_redirect error: %s", exc) - return _token_error_response(exc) - -``` - -**refresh** - -``` -@auth_bp.route("/refresh", methods=["POST"]) -def refresh_token(): - """Re-validate the user session and return a new access token using the refresh token. - - When an access token expires, the client can call this endpoint with the refresh token - to obtain a new access token without requiring the user to log in again. The client - can also pass the desired scopes for the new access token, which must be a subset - of the original scopes granted to the refresh token. - - Parameters (in JSON body): - - ``refresh_token`` (string, required): The refresh token issued by the OIDC provider. - - ``scope`` (string, optional): Space-separated list of scopes to request for the new access token. If omitted, the new access token will have the same scopes as the original token. - - Returns: - 200 JSON with new ``access_token`` and ``refresh_token`` on success. - 400 JSON if the request is missing required parameters. - 401 JSON if the refresh token is invalid, expired, or if client authentication fails. - 500 JSON for unexpected server errors. - """ - - adapter = current_app.extensions.get('dataone_auth') - - # Get the refresh token and desired scopes from the JSON body - data = request.get_json(silent=True) - if not data: - return _token_error_response(MissingParameterError("refresh_token")) - - user_refresh_token = data.get("refresh_token") - if not user_refresh_token: - return _token_error_response(MissingParameterError("refresh_token")) - - # The client should pass the scopes that it would like to request for the - # new access token. If no scopes are provided, we will attempt to get a - # new access token with the same scopes as the original token. The - # requested scopes must match or be a subset of the original scopes granted - # to the token, otherwise the OIDC provider will reject the request. - requested_scope = data.get("scope") - - # Use Authlib to exchange the refresh token for a new access token - try: - oidc_client = adapter.vegbank_oidc # maybe get this dynamically - if not requested_scope: - # If no scope is provided, omit the scope parameter to get the same scopes as the original token - new_tokens = oidc_client.fetch_access_token( - grant_type="refresh_token", - refresh_token=user_refresh_token, - ) - else: - new_tokens = oidc_client.fetch_access_token( - grant_type="refresh_token", - refresh_token=user_refresh_token, - scope=requested_scope, - ) - return _token_response(new_tokens, message="Authorization successful") - except InvalidGrantError as exc: - # The refresh token was invalid, expired, or revoked by the provider - logger.debug("The refresh token is invalid or expired: %s", exc) - return _token_error_response(exc) - except InvalidClientError as exc: - # The client_id or client_secret is wrong - logger.warning("OIDC client authentication failed: %s", exc) - return _token_error_response(exc) - except OAuth2Error as exc: - logger.debug("An OAuth2 error occurred: %s", exc) - return _token_error_response(exc) - except Exception as exc: - # A safety net for non-OAuth errors (e.g., network issues) - logger.error("Unexpected Exception during refresh: %s", exc, exc_info=True) - return _token_error_response(exc) - -``` - -**authorize** - -``` - -@auth_bp.route("/authorize", methods=["GET"]) -def authorize(): - """OIDC authorization callback endpoint. - - Keycloak redirects here after a successful login with a short-lived - authorization code. This endpoint exchanges that code for an access - token, stores the token and returns it to the caller. - - Returns: - 200 JSON with ``token`` on success. - 401 JSON with error details on failure. - 403 JSON response if authentication is disabled for the current access mode. - """ - mode = get_access_mode() - if mode != ACCESS_MODE_AUTHENTICATED: - return _auth_error_response(f"Authentication is disabled in '{mode}' mode.", 403) - - adapter = current_app.extensions.get('dataone_auth') - oidc_client = adapter.vegbank_oidc - - try: - token = oidc_client.authorize_access_token() - except (OAuthError, RequestException) as exc: - logger.debug("OIDC token exchange error: %s", exc) - return _token_error_response(exc) - - return _token_response(token, message="Authorization successful") - -``` - -### Response/Error Classes - -``` -def _auth_error_response(message, status, details=None): - """Generate a uniform JSON error response for authentication/authorization errors. - - All auth-related error responses should use this helper to guarantee a consistent ``{"error": {"message": ..., "details": ...}}`` object. - - Args: - message: Error description. - status: HTTP status code. - details: Optional additional context (``str(exc)``). Omitted from the response when *None*. - - Returns: - Tuple of (JSON response, status code). - """ - error = {"message": message} - if details is not None: - error["details"] = details - return jsonify({"error": error}), status - - -def _token_error_response(exc): - """Produce a uniform JSON error response for token validation/exchange failures.""" - error_map = { - DecodeError: ("Token decoding failed", 401), - InvalidClientError: ("OIDC client authentication failed", 401), - InvalidTokenError: ("Token validation failed", 401), - InvalidGrantError: ("Invalid or expired refresh token", 401), - BadSignatureError: ("Token signature verification failed", 401), - OAuthError: ("Authorization failed", 401), - OAuth2Error: ("An OAuth2 error occurred", 401), - KeyError: ("Invalid token structure", 401), - TypeError: ("Invalid token structure", 401), - MissingParameterError: ("Missing required parameter", 400), - ValueError: ("OIDC provider configuration error", 500), - _requests.RequestException: ("Failed to fetch OIDC provider keys", 502), - } - for exc_types, (message, status) in error_map.items(): - if isinstance(exc, exc_types): - return _auth_error_response(message, status, details=str(exc)) - # Unexpected exception — treat as server error - return _auth_error_response("Internal authentication error", 500, details=str(exc)) - - -def _token_response(token: dict, message: str = "Token exchange successful"): - """Produce a uniform JSON response with access and refresh tokens. - - Args: - token: Dict containing token data with 'access_token' and 'refresh_token' keys. - message: Optional message to include in response. - - Returns: - Tuple of (JSON response, 200 status code). - """ - return ( - jsonify( - { - "message": message, - "token": { - "access_token": token.get("access_token"), - "refresh_token": token.get("refresh_token"), - }, - } - ), - 200, - ) - -``` - -## FastAPI - -``` -from fastapi import FastAPI -from dataone.factory import AuthFactory -from dataone.auth import load_client_secrets -import logging - -logger = logging.getLogger(__name__) - -# 1. Create the FastAPI instance -app = FastAPI() - -def init_auth_client(): - """Initialise the DataOne Auth Client for FastAPI.""" - - try: - ogdc_secrets = load_client_secrets() - except Exception as exc: - logger.warning("Auth unavailable: %s", exc) - return None - - # Define ogdc-specific scopes if they differ, or use defaults - ogdc_scopes = ["ogdc:admin", "ogdc:user"] - - # This might return an 'httpx' based Async client instead of a 'requests' one - auth_client = AuthFactory.create_client("fastapi", ogdc_secrets, ogdc_scopes) - - return auth_client - -# 3. Store it in the app state for easy access -app.state.auth = init_auth_client() - -``` \ No newline at end of file From c895b06dff2bcad0be4f0da71a8616cffd1a06e0 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 15 May 2026 15:07:52 -0700 Subject: [PATCH 40/63] add require_token decorator/depends --- src/dataone/auth.py | 130 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 125 insertions(+), 5 deletions(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index b0e6730..f321a88 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -423,12 +423,14 @@ def validate_and_extract_claims(self, token_str: str, required_scope: str = None The validated claims dict. Raises: - Exception: JoseError from Authlib if token is invalid/expired. + JoseError: If the token is invalid, expired, or has an incorrect + issuer/audience. InsufficientScopeError: If the token lacks the required scope. """ claims = self._decode_and_validate_token(token_str) - self._verify_scope(claims, required_scope) + if required_scope: + self._verify_scope(claims, required_scope) return claims @@ -448,6 +450,10 @@ def require_scope(self, required_scope: str): """This is implemented by subclasses.""" raise NotImplementedError + def require_token(self): + """This is implemented by subclasses.""" + raise NotImplementedError + def __getattr__(self, name): """ Delegate all unknown attribute/method lookups to the underlying Authlib OAut @@ -575,7 +581,8 @@ async def validate_and_extract_claims(self, """ claims = await self._decode_and_validate_token(token_str) - self._verify_scope(claims, required_scope) + if required_scope: + self._verify_scope(claims, required_scope) return claims @@ -682,6 +689,9 @@ def require_scope(self, required_scope: str): Args: required_scope: The specific OAuth scope required to access the route (e.g., "read:data" or "write:admin"). + methods: Optional list of HTTP method names (e.g., ['POST', 'PUT']) to + protect. If None, all methods are protected. If the current request + method is not in this list, authentication is bypassed. Returns: An asynchronous callable dependency that returns validated token claims. @@ -724,6 +734,63 @@ async def dependency(request: Request): ) return dependency + def require_token(self, methods=None): + """Creates a FastAPI dependency to enforce token requirements on routes. + + This method returns an async function designed to be injected into FastAPI + endpoints using `Depends()`. It extracts the token, validates it, and returns + the claims. If the adapter's access mode is not set to 'authenticated' (e.g., + 'read_only'), validation is bypassed. + + Args: + methods: Optional list of HTTP method names (e.g., ['POST', 'PUT']) to + protect. If None, all methods are protected. If the current request + method is not in this list, authentication is bypassed. + + Returns: + An asynchronous callable dependency that returns validated token claims. + + Raises: + fastapi.HTTPException: If token validation fails. The internal exception + is translated into a standard FastAPI HTTP error + using the adapter's error handler. + + Example: + from fastapi import Depends + + @app.get("/secure-data") + async def get_secure_data( + claims: dict = Depends(auth_adapter.require_token(methods=["POST"])) + ): + return {"message": "Access granted", "user": claims.get("sub")} + """ + from fastapi import Request + async def dependency(request: Request): + from fastapi import HTTPException + + from .auth import extract_token_from_header + # Handle 'read_only' logic + if self.access_mode != "authenticated": + return None + + if methods is not None and request.method not in methods: + return None + + try: + auth_header = request.headers.get("Authorization") + token = extract_token_from_header(auth_header) + # This call is async in FastAPI + claims = await self.validate_and_extract_claims(token) + return claims + except Exception as e: + # In FastAPI, we RAISE the error handler's result + error_res = self._error_handler(e) + raise HTTPException( + status_code=error_res.status_code, + detail=json.loads(error_res.body.decode())["error"] + ) + return dependency + class FlaskAuthAdapter(BaseAuthAdapter): def _initialize_oauth(self): """Initializes the Flask-based OAuth registry. @@ -852,7 +919,7 @@ def refresh_route(): except Exception as e: return self._error_handler(e) - def require_scope(self, required_scope: str): + def require_scope(self, required_scope: str, methods: None): """Creates a Flask decorator to enforce scope requirements on routes. This method returns a decorator that extracts the Bearer token from the @@ -864,6 +931,9 @@ def require_scope(self, required_scope: str): Args: required_scope: The specific OAuth scope required to access the route (e.g., "read:data"). + methods: Optional list of HTTP method names (e.g., ['POST', 'PUT']) to + protect. If None, all methods are protected. If the current request + method is not in this list, authentication is bypassed. Returns: A decorator function that wraps a Flask route handler. @@ -877,9 +947,12 @@ def get_secure_data(claims): def decorator(f): @functools.wraps(f) def decorated(*args, **kwargs): - # Handle the 'read_only' logic inside the adapter + from flask import request if self.access_mode != "authenticated": return f(None, *args, **kwargs) + + if methods is not None and request.method not in methods: + return f(None, *args, **kwargs) try: from flask import request @@ -893,3 +966,50 @@ def decorated(*args, **kwargs): return decorated return decorator + def require_token(self, methods=None): + """Creates a Flask decorator to enforce token authentication on routes. + + This method returns a decorator that extracts the Bearer token from the + 'Authorization' header, validates it, and injects the resulting claims + into the decorated function as the first argument. If the adapter is in + 'read_only' or 'open' mode, validation is bypassed and 'None' is passed + for the claims. + + Args: + methods: Optional list of HTTP method names (e.g., ['POST', 'PUT']) to + protect. If None, all methods are protected. If the current request + method is not in this list, authentication is bypassed. + + Returns: + A decorator function that wraps a Flask route handler. + + Example: + @app.route("/any-authenticated-user", methods=["GET", "POST"]) + @auth_adapter.require_token(methods=["POST"]) + def handle_data(claims): + user_id = claims.get("sub") if claims else "Anonymous" + return {"message": "Success", "user": user_id} + """ + def decorator(f): + @functools.wraps(f) + def decorated(*args, **kwargs): + from flask import request + mode = self.get_access_mode() + if mode != "authenticated": + return f(None, *args, **kwargs) + + # filter http methods + if methods is not None and request.method not in methods: + return f(None, *args, **kwargs) + + try: + from flask import request + token = extract_token_from_header( + request.headers.get("Authorization")) + claims = self.validate_and_extract_claims(token) + # Pass claims into the route + return f(claims, *args, **kwargs) + except Exception as e: + return self._error_handler(e) + return decorated + return decorator From ba8026e63f12510cf1c7878c92eaf9cf3777b1f5 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 15 May 2026 15:16:13 -0700 Subject: [PATCH 41/63] fix up methods in fn sigs --- src/dataone/auth.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index f321a88..cf9b602 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -446,11 +446,11 @@ def refresh(self, request_json: dict): """This is implemented by subclasses.""" raise NotImplementedError - def require_scope(self, required_scope: str): + def require_scope(self, required_scope: str, methods=None): """This is implemented by subclasses.""" raise NotImplementedError - def require_token(self): + def require_token(self, methods=None): """This is implemented by subclasses.""" raise NotImplementedError @@ -678,7 +678,7 @@ async def refresh(request: Request): except Exception as e: return self._error_handler(e) - def require_scope(self, required_scope: str): + def require_scope(self, required_scope: str, methods=None): """Creates a FastAPI dependency to enforce scope requirements on routes. This method returns an async function designed to be injected into FastAPI From bf2bd54a4ce45519402830bea234afd70defd444 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 15 May 2026 15:18:17 -0700 Subject: [PATCH 42/63] make access mode call consistent with the rest --- src/dataone/auth.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index cf9b602..c9d83c9 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -994,8 +994,7 @@ def decorator(f): @functools.wraps(f) def decorated(*args, **kwargs): from flask import request - mode = self.get_access_mode() - if mode != "authenticated": + if self.access_mode != "authenticated": return f(None, *args, **kwargs) # filter http methods From 37f7c9f37d5108c05b60c29a7042b1cc24171b62 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 15 May 2026 15:23:38 -0700 Subject: [PATCH 43/63] add require_token to docs --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6c17ef9..9a37097 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ To run the code formatter and linter, use Ruff: ### Flask -Below is a minimal example for a Flask application. For the Flask implementation, applying `ProxyFix` is recommended to ensure correct redirect URIs when the app is running behind a reverse proxy or load balancer. Following standard Flask extension patterns, the `auth_client` must be explicitly bound to the application using `init_app()`. Once initialized, protect any endpoint by stacking the `@auth_client.require_scope(...)` decorator below the route definition. This automatically intercepts the Bearer token, validates the OIDC claims against the provider's JWKS, and injects the resulting claims dictionary into the view function. +Below is a minimal example for a Flask application. For the Flask implementation, applying `ProxyFix` is recommended to ensure correct redirect URIs when the app is running behind a reverse proxy or load balancer. Following standard Flask extension patterns, the `auth_client` must be explicitly bound to the application using `init_app()`. Once initialized, protect any endpoint by stacking the `@auth_client.require_scope(...)` decorator below the route definition. This automatically intercepts the Bearer token, validates the OIDC claims against the provider's JWKS, and injects the resulting claims dictionary into the view function. Note that routes can also use `@auth_client.require_token(...)` if checking scopes is not necessary. Optionally, HTTP methods can be passed to either decorator to specify auth requirements based on method if necessary. ```python import json @@ -96,7 +96,7 @@ if __name__ == "__main__": ### FastAPI -Below is a minimal example for a FastAPI application. Unlike Flask, FastAPI doesn't require an `init_app` step; the `auth_client` is ready to use immediately upon creation. Note that `SessionMiddleware` must be added to the app to handle the OIDC state and nonce during the browser-based login and authorization flow. For the API endpoints, the heavy lifting happens within the `Depends(auth_client.require_scope(...))` dependency, which automatically intercepts the Bearer token, validates the OIDC claims against the provider's JWKS, and injects the ready-to-use claims dictionary into the route handler. +Below is a minimal example for a FastAPI application. Unlike Flask, FastAPI doesn't require an `init_app` step; the `auth_client` is ready to use immediately upon creation. Note that `SessionMiddleware` must be added to the app to handle the OIDC state and nonce during the browser-based login and authorization flow. For the API endpoints, the heavy lifting happens within the `Depends(auth_client.require_scope(...))` dependency, which automatically intercepts the Bearer token, validates the OIDC claims against the provider's JWKS, and injects the ready-to-use claims dictionary into the route handler. Note that routes can also use `Depends(auth_client.require_token(...))` if checking scopes is not necessary. Optionally, HTTP methods can be passed to either `Depends` to specify auth requirements based on method if necessary. ```python import os From b2b345944cd569eb2b3e8d93801cbffdbaa606d0 Mon Sep 17 00:00:00 2001 From: Matt Jones Date: Tue, 19 May 2026 14:06:53 -0800 Subject: [PATCH 44/63] Omit __init__.py from a namespace package. See https://packaging.python.org/en/latest/guides/packaging-namespace-packages/ --- pyproject.toml | 7 +++---- src/dataone/__init__.py | 2 -- 2 files changed, 3 insertions(+), 6 deletions(-) delete mode 100644 src/dataone/__init__.py diff --git a/pyproject.toml b/pyproject.toml index 562042a..5159abe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,11 @@ [project] name = "dataone-auth" -version = "0.1.0" +version = "0.2.0" description = "DataONE OIDC Auth package" readme = "README.md" authors = [ + { name = "Rushiraj Nenuji", email = "nenuji@nceas.ucsb.edu" }, + { name = "Jeanette Clark", email = "jclark@nceas.ucsb.edu" }, { name = "Matthew B. Jones", email = "jones@nceas.ucsb.edu" } ] requires-python = ">=3.13" @@ -16,9 +18,6 @@ dependencies = [ "werkzeug>=3.1.8", ] -[project.scripts] -dataone = "dataone:main" - [project.optional-dependencies] flask = [ "flask>=3.1.3", diff --git a/src/dataone/__init__.py b/src/dataone/__init__.py deleted file mode 100644 index 701f23c..0000000 --- a/src/dataone/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -def main() -> None: - print("Hello from dataone!") From 258339298fc078216a005ee35a3357027415f2b3 Mon Sep 17 00:00:00 2001 From: Matt Jones Date: Tue, 19 May 2026 14:20:56 -0800 Subject: [PATCH 45/63] Bump version, add module docs, reformat file with ruff. --- src/dataone/auth.py | 409 ++++++++++++++++++++++++-------------------- uv.lock | 2 +- 2 files changed, 226 insertions(+), 185 deletions(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index c9d83c9..b4458f7 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -1,3 +1,16 @@ +"""Authentication and token validation utilities for DataONE authorization. + +This module provides helpers to load OIDC client secrets, extract and validate Bearer +tokens, decode JWT claims, and enforce access and scope checks used by the DataONE +auth flow. The package can be used with multiple web frameworks via the AuthFactory, +which uses specific adapters (e.g., FlaskAuthAdapter, FastAPIAuthAdapter) to integrate +with the request handling and dependency injection patterns of each framework. The base +adapter handles the core logic of OIDC provider setup, token validation, and error +handling, while the adapters implement the framework-specific request processing and +response formatting. This design allows for flexible integration with different Python +web frameworks without hard dependencies on any particular framework. +""" + import functools import json import os @@ -27,51 +40,61 @@ ### Exceptions + class AuthError(Exception): """Base exception for dataone-auth.""" + pass + class MissingParameterError(AuthError): """Raised when a required request parameter is missing.""" + class InsufficientScopeError(AuthError): """Raised when the token is valid but doesn't have the right scope.""" + pass + class TokenExtractionError(AuthError): """Raised when the Authorization header is missing or malformed.""" + pass + class InvalidTokenError(AuthError): """Raised when claims like iss or aud do not match expectations.""" + pass + class ConfigurationError(AuthError): pass + ### Helpers + def load_client_secrets(filepath: str | None = None) -> dict: """Load client secrets from a JSON file. Args: filepath: Optional explicit path. Falls back to the ``OIDC_CLIENT_SECRETS_FILE`` - environment variable, then finally to the default path of + environment variable, then finally to the default path of "./client_secrets.json" Returns: Parsed dict of client credentials. Raises: - ConfigurationError: If the secrets file cannot be found at the resolved path, + ConfigurationError: If the secrets file cannot be found at the resolved path, or if the file does not contain valid JSON. """ # accept either explicit filepath argument or environment variable, with a default # fallback resolved = ( - filepath - or os.getenv("OIDC_CLIENT_SECRETS_FILE") - or _DEFAULT_SECRETS_PATH + filepath or os.getenv("OIDC_CLIENT_SECRETS_FILE") or _DEFAULT_SECRETS_PATH ) try: with open(resolved) as f: @@ -81,12 +104,13 @@ def load_client_secrets(filepath: str | None = None) -> dict: except json.JSONDecodeError: raise ConfigurationError(f"OIDC secrets file at {resolved} is not valid JSON") + def extract_token_from_header(auth_header: str): """Extracts and validates a Bearer token from an auth header string. - + Args: auth_header: Auth header as a string (e.g., "Bearer "). - + Returns: The extracted JWT token. @@ -94,32 +118,32 @@ def extract_token_from_header(auth_header: str): MissingParameterError: If no header is supplied. TokenExtractionError: If the token is empty, malformed, or exceeds the allowed length. - """ - + if not auth_header: raise MissingParameterError("Missing Authorization header") if not auth_header.startswith("Bearer "): raise TokenExtractionError( "Invalid Authorization header format. Expected 'Bearer '" - ) + ) token = auth_header[7:].strip() - + if not token: raise TokenExtractionError("Token is empty") - + # Check JWT structure - if token.count('.') != 2: + if token.count(".") != 2: raise TokenExtractionError("Token is malformed (invalid JWT structure)") # DoS protection if len(token) > MAX_TOKEN_LEN: raise TokenExtractionError("Token exceeds maximum allowed length") - + return token + def extract_orcid(claims: dict | None) -> str | None: """Extract a normalised ORCID iD URI from JWT claims. @@ -143,9 +167,9 @@ def extract_orcid(claims: dict | None) -> str | None: # Strip http(s)://orcid.org/ prefix, leaving just the bare ID if raw.startswith(_ORCID_HTTPS_PREFIX): - bare = raw[len(_ORCID_HTTPS_PREFIX):] + bare = raw[len(_ORCID_HTTPS_PREFIX) :] elif raw.startswith(_ORCID_HTTP_PREFIX): - bare = raw[len(_ORCID_HTTP_PREFIX):] + bare = raw[len(_ORCID_HTTP_PREFIX) :] else: bare = raw @@ -155,11 +179,12 @@ def extract_orcid(claims: dict | None) -> str | None: return _ORCID_HTTPS_PREFIX + bare + def get_access_mode() -> str: """Get the current access mode from environment. - + Returns: - str: One of 'read_only', 'open', or 'authenticated'. Defaults to + str: One of 'read_only', 'open', or 'authenticated'. Defaults to 'authenticated'. """ mode = os.getenv("ACCESS_MODE", "authenticated").lower() @@ -170,44 +195,46 @@ def get_access_mode() -> str: def decode_claims(token_str, jwks, client_id, issuer): """Decodes and validates a JWT using joserfc. - + Args: token_str: The raw encoded JWT string. jwks: The KeySet object returned by _get_jwks_keys. client_id: The expected audience (aud) and authorized party (azp). issuer: The expected issuer (iss) URI of the token. - + Returns: The validated claims object. - + Raises: - ValueError: If the issuer, audience, or azp claims do not match + ValueError: If the issuer, audience, or azp claims do not match the expected values. """ token = jwt.decode(token_str, jwks) - + # standard joserfc validation (checks exp, nbf, etc.) claims = token.claims registry = jwt.JWTClaimsRegistry() registry.validate(claims) - + if claims.get("iss") != issuer: raise InvalidTokenError("Invalid issuer") if claims.get("aud") != client_id: raise InvalidTokenError("Invalid audience") if claims.get("azp") and claims.get("azp") != client_id: raise InvalidTokenError("Invalid authorized party (azp)") - + return claims + ### Factory + class AuthFactory: """Factory for generating framework-specific authentication adapters. - + This factory uses a registry and dynamic imports to instantiate the correct adapter (e.g., Flask or FastAPI) based on the running application. This pattern - ensures that a Flask application does not need to install FastAPI/Starlette + ensures that a Flask application does not need to install FastAPI/Starlette dependencies, and vice versa. """ @@ -220,44 +247,45 @@ class AuthFactory: @classmethod def create_client(cls, framework: str, secrets: dict, scopes: list): """Creates and returns the appropriate authentication adapter. - + Args: - framework: A string identifying the target web framework (e.g., "flask", + framework: A string identifying the target web framework (e.g., "flask", "fastapi"). - secrets: A dictionary containing the OIDC client credentials, typically + secrets: A dictionary containing the OIDC client credentials, typically loaded via `load_client_secrets()`. - scopes: A list of default OIDC scopes to request from the authorization + scopes: A list of default OIDC scopes to request from the authorization server (e.g., ["ogdc:admin"]). - + Returns: - BaseAuthAdapter: An instantiated, framework-specific adapter (such as + BaseAuthAdapter: An instantiated, framework-specific adapter (such as `FlaskAuthAdapter` or `FastAPIAuthAdapter`). - + Raises: ValueError: If the framework string is not found in the registry. """ import_path = cls._registry.get(framework.lower()) if not import_path: raise ValueError(f"Unsupported framework: {framework}") - + module_path, class_name = import_path.rsplit(".", 1) module = __import__(module_path, fromlist=[class_name]) AdapterClass = getattr(module, class_name) - + return AdapterClass(secrets=secrets, scopes=scopes) + class BaseAuthAdapter: """Base adapter for handling OIDC authentication. - - This class manages the core Authlib registry initialization, OIDC provider - setup, and access mode configuration. It is designed to be subclassed by - framework-specific adapters (e.g., FlaskAuthAdapter, FastAPIAuthAdapter) + + This class manages the core Authlib registry initialization, OIDC provider + setup, and access mode configuration. It is designed to be subclassed by + framework-specific adapters (e.g., FlaskAuthAdapter, FastAPIAuthAdapter) that implement the actual request handling and dependency/decorator logic. - + Attributes: DEFAULT_PROVIDER_NAME (str): The internal registry name for the OIDC provider. DEFAULT_SCOPES (str): The standard base scopes requested during login. - access_mode (str): The current operating mode ('authenticated', 'read_only', + access_mode (str): The current operating mode ('authenticated', 'read_only', or 'open'), loaded during initialization. """ @@ -266,8 +294,8 @@ class BaseAuthAdapter: ERROR_MAP = { TokenExtractionError: ("Invalid token or header", 401), - JoseError: ("Token decoding or signature verification failed", 401), # <- New - InvalidTokenError: ("Token validation failed", 401), # <- Now your custom error + JoseError: ("Token decoding or signature verification failed", 401), # <- New + InvalidTokenError: ("Token validation failed", 401), # <- Now your custom error InvalidClientError: ("OIDC client authentication failed", 401), InvalidGrantError: ("Invalid or expired refresh token", 401), OAuthError: ("Authorization failed", 401), @@ -281,7 +309,7 @@ class BaseAuthAdapter: def __init__(self, secrets, scopes): """Initializes the base authentication adapter. - + Args: secrets: Dictionary of OIDC client credentials. scopes: List of additional OIDC scopes to request. @@ -307,13 +335,13 @@ def _setup_providers(self): client_id=self.secrets.get("client_id"), client_secret=self.secrets.get("client_secret"), server_metadata_url=self.secrets.get("server_metadata_url"), - client_kwargs={"scope": scope_request}, - ) + client_kwargs={"scope": scope_request}, + ) def _resolve_error(self, exc: Exception): """Resolves an exception to an error message and HTTP status code. - - Evaluates the exception against ERROR_MAP, matching by direct class type + + Evaluates the exception against ERROR_MAP, matching by direct class type or class name (string) to avoid hard dependency imports. Args: @@ -326,13 +354,14 @@ def _resolve_error(self, exc: Exception): for exc_type, (msg, code) in self.ERROR_MAP.items(): # Parentheses let us wrap this logic across lines cleanly is_match = ( - isinstance(exc, exc_type) if not isinstance(exc_type, str) + isinstance(exc, exc_type) + if not isinstance(exc_type, str) else type(exc).__name__ == exc_type ) - + if is_match: return msg, code - + return "Internal authentication error", 500 def _verify_scope(self, claims: dict, required_scope: str | None): @@ -343,8 +372,7 @@ def _verify_scope(self, claims: dict, required_scope: str | None): token_scopes = claims.get("scope", "").split() if required_scope not in token_scopes: raise InsufficientScopeError( - f"Required: '{required_scope}'. " - f"Available: {token_scopes}" + f"Required: '{required_scope}'. Available: {token_scopes}" ) def _error_handler(self, exc: Exception): @@ -358,8 +386,8 @@ def _token_response(self, token: dict, message: str): def _get_jwks_keys(self): """Fetch and cache the JWKS signing keys from the OIDC provider. - These keys are used to validate JWT token signatures. Care must be taken to - fetch them only from trustworthy sources (via the OIDC provider's metadata + These keys are used to validate JWT token signatures. Care must be taken to + fetch them only from trustworthy sources (via the OIDC provider's metadata endpoint over HTTPS). The keys may change periodically, so the cache will be invalidated and keys will be refetched on the next call after the application is restarted. @@ -372,7 +400,7 @@ def _get_jwks_keys(self): requests.RequestException: If errors while fetching the JWKS. """ - if hasattr(self, '_cached_jwks'): + if hasattr(self, "_cached_jwks"): return self._cached_jwks provider = getattr(self.oauth, self.DEFAULT_PROVIDER_NAME) @@ -392,8 +420,8 @@ def _get_jwks_keys(self): def _decode_and_validate_token(self, token_str: str): """Decodes and validates a JWT using the provider's JWKS. - - Enforces signature validity as well as the exact issuer (iss), + + Enforces signature validity as well as the exact issuer (iss), audience (aud), and authorized party (azp) claims. Args: @@ -403,7 +431,7 @@ def _decode_and_validate_token(self, token_str: str): The validated token claims object. """ jwks = self._get_jwks_keys() - + provider = getattr(self.oauth, self.DEFAULT_PROVIDER_NAME) metadata = provider.load_server_metadata() issuer = metadata.get("issuer") @@ -411,27 +439,27 @@ def _decode_and_validate_token(self, token_str: str): client_id = self.secrets.get("client_id") return decode_claims(token_str, jwks, client_id, issuer) - + def validate_and_extract_claims(self, token_str: str, required_scope: str = None): """Validate a token string and optionally check required scope. - + Args: token_str: The raw JWT string. required_scope: Optional scope string to validate. - + Returns: The validated claims dict. - + Raises: JoseError: If the token is invalid, expired, or has an incorrect issuer/audience. InsufficientScopeError: If the token lacks the required scope. """ claims = self._decode_and_validate_token(token_str) - + if required_scope: self._verify_scope(claims, required_scope) - + return claims def login(self, redirect_uri: str, request=None): @@ -449,11 +477,11 @@ def refresh(self, request_json: dict): def require_scope(self, required_scope: str, methods=None): """This is implemented by subclasses.""" raise NotImplementedError - + def require_token(self, methods=None): """This is implemented by subclasses.""" raise NotImplementedError - + def __getattr__(self, name): """ Delegate all unknown attribute/method lookups to the underlying Authlib OAut @@ -461,52 +489,49 @@ def __getattr__(self, name): """ return getattr(self.oauth, name) + ### Adapters + class FastAPIAuthAdapter(BaseAuthAdapter): def _initialize_oauth(self): """Initializes the Starlette-based OAuth registry for FastAPI. - - Sets the internal response class to FastAPI's JSONResponse and returns + + Sets the internal response class to FastAPI's JSONResponse and returns the instantiated Authlib OAuth object. """ from authlib.integrations.starlette_client import OAuth from fastapi.responses import JSONResponse + self._response_class = JSONResponse return OAuth() def _error_handler(self, exc: Exception): """Formats an exception into a FastAPI JSON response. - + Args: exc: The exception caught during authentication or request processing. Returns: - A JSONResponse object containing the resolved HTTP status code + A JSONResponse object containing the resolved HTTP status code and formatted error payload. """ msg, code = self._resolve_error(exc) return self._response_class( - status_code=code, - content={ - "error": { - "message": msg, - "details": str(exc) - } - } + status_code=code, content={"error": {"message": msg, "details": str(exc)}} ) def _token_response(self, token: dict, message: str = "Success"): """Formats successful token data into a FastAPI JSON response. - + Args: - token: A dictionary containing the token data (must include at least + token: A dictionary containing the token data (must include at least 'access_token' and 'refresh_token' keys). - message: An optional success message to include in the response payload. + message: An optional success message to include in the response payload. Defaults to "Success". Returns: - A JSONResponse object with a 200 status code and the standardized + A JSONResponse object with a 200 status code and the standardized token payload. """ return self._response_class( @@ -516,15 +541,15 @@ def _token_response(self, token: dict, message: str = "Success"): "token": { "access_token": token.get("access_token"), "refresh_token": token.get("refresh_token"), - } - } + }, + }, ) async def _get_jwks_keys(self): """Asynchronously fetches and caches the OIDC provider's JWKS. - - Retrieves the provider metadata to find the `jwks_uri`, makes a non-blocking - HTTP request to fetch the keys using `httpx`, and caches the parsed key set + + Retrieves the provider metadata to find the `jwks_uri`, makes a non-blocking + HTTP request to fetch the keys using `httpx`, and caches the parsed key set to prevent redundant network calls. Returns: @@ -534,27 +559,27 @@ async def _get_jwks_keys(self): ValueError: If the provider metadata does not contain a 'jwks_uri'. httpx.HTTPStatusError: If the network request to the `jwks_uri` fails. """ - if hasattr(self, '_cached_jwks'): - return self._cached_jwks + if hasattr(self, "_cached_jwks"): + return self._cached_jwks provider = getattr(self.oauth, self.DEFAULT_PROVIDER_NAME) metadata = await provider.load_server_metadata() - + jwks_uri = metadata.get("jwks_uri") if not jwks_uri: raise ValueError("OIDC provider metadata missing 'jwks_uri'") - + async with httpx.AsyncClient() as client: response = await client.get(jwks_uri, timeout=10) response.raise_for_status() - + self._cached_jwks = KeySet.import_key_set(response.json()) return self._cached_jwks async def _decode_and_validate_token(self, token_str: str): """Async override for decoding.""" jwks = await self._get_jwks_keys() - + provider = getattr(self.oauth, self.DEFAULT_PROVIDER_NAME) # Starlette requires await here too metadata = await provider.load_server_metadata() @@ -564,13 +589,13 @@ async def _decode_and_validate_token(self, token_str: str): return decode_claims(token_str, jwks, client_id, issuer) - async def validate_and_extract_claims(self, - token_str: str, - required_scope: str = None): + async def validate_and_extract_claims( + self, token_str: str, required_scope: str = None + ): """Asynchronously decodes and validates a JWT using the provider's JWKS. - - This overrides the base method to support Starlette/FastAPI's asynchronous - metadata and JWKS fetching. It enforces signature validity as well as exact + + This overrides the base method to support Starlette/FastAPI's asynchronous + metadata and JWKS fetching. It enforces signature validity as well as exact matching for issuer (iss), audience (aud), and authorized party (azp) claims. Args: @@ -580,26 +605,26 @@ async def validate_and_extract_claims(self, The validated token claims object. """ claims = await self._decode_and_validate_token(token_str) - + if required_scope: self._verify_scope(claims, required_scope) - + return claims - + async def login(self, request, redirect_uri: str): """Asynchronously initiates the OIDC login flow. - Uses the Starlette OAuth client to generate a redirect response that + Uses the Starlette OAuth client to generate a redirect response that sends the user to the authorization server. Args: request: The incoming Starlette or FastAPI Request object. - redirect_uri: The callback URL where the authorization server will + redirect_uri: The callback URL where the authorization server will redirect the user after authentication. Returns: A Starlette RedirectResponse object pointing to the OIDC provider. - + Example: @app.get("/login") async def login(request: Request): @@ -614,15 +639,15 @@ async def login(request: Request): async def authorize(self, request): """Asynchronously exchanges an authorization code for an access token. - This method is designed to be used in the OIDC callback route. It - processes the incoming redirect from the authorization server, extracts + This method is designed to be used in the OIDC callback route. It + processes the incoming redirect from the authorization server, extracts the code, and fetches the final tokens. Args: request: The incoming FastAPI Request object containing the auth code. Returns: - A JSONResponse containing the extracted tokens on success, or a + A JSONResponse containing the extracted tokens on success, or a formatted error response on failure. Example: @@ -640,15 +665,15 @@ async def authorize(request: Request): async def refresh(self, request_json: dict): """Asynchronously exchanges a refresh token for new access tokens. - Overrides the synchronous base method to accommodate FastAPI's async - token fetching. + Overrides the synchronous base method to accommodate FastAPI's async + token fetching. Args: - request_json: A dictionary (typically the parsed JSON body of the + request_json: A dictionary (typically the parsed JSON body of the request) containing at least a 'refresh_token'. Returns: - A JSONResponse containing the new access and refresh tokens, or an + A JSONResponse containing the new access and refresh tokens, or an error response if the token is missing or invalid. Example: @@ -661,17 +686,14 @@ async def refresh(request: Request): if not refresh_token: # This triggers our mapped TokenExtractionError (401) return self._error_handler(TokenExtractionError("Missing refresh_token")) - + scope = request_json.get("scope") - + try: - kwargs = { - "grant_type": "refresh_token", - "refresh_token": refresh_token - } + kwargs = {"grant_type": "refresh_token", "refresh_token": refresh_token} if scope: kwargs["scope"] = scope - + # The Starlette fetch_access_token is async new_tokens = await self.dataone_oidc.fetch_access_token(**kwargs) return self._token_response(new_tokens, message="Token refresh successful") @@ -681,15 +703,15 @@ async def refresh(request: Request): def require_scope(self, required_scope: str, methods=None): """Creates a FastAPI dependency to enforce scope requirements on routes. - This method returns an async function designed to be injected into FastAPI - endpoints using `Depends()`. It extracts the token, validates it against - the requested scope, and returns the claims. If the adapter's access mode + This method returns an async function designed to be injected into FastAPI + endpoints using `Depends()`. It extracts the token, validates it against + the requested scope, and returns the claims. If the adapter's access mode is not set to 'authenticated' (e.g., 'read_only'), validation is bypassed. Args: - required_scope: The specific OAuth scope required to access the route + required_scope: The specific OAuth scope required to access the route (e.g., "read:data" or "write:admin"). - methods: Optional list of HTTP method names (e.g., ['POST', 'PUT']) to + methods: Optional list of HTTP method names (e.g., ['POST', 'PUT']) to protect. If None, all methods are protected. If the current request method is not in this list, authentication is bypassed. @@ -697,13 +719,13 @@ def require_scope(self, required_scope: str, methods=None): An asynchronous callable dependency that returns validated token claims. Raises: - fastapi.HTTPException: If token validation fails. The internal exception - is translated into a standard FastAPI HTTP error + fastapi.HTTPException: If token validation fails. The internal exception + is translated into a standard FastAPI HTTP error using the adapter's error handler. Example: from fastapi import Depends - + @app.get("/secure-data") async def get_secure_data( claims: dict = Depends(auth_adapter.require_scope("read:data")) @@ -711,14 +733,16 @@ async def get_secure_data( return {"message": "Access granted", "user": claims.get("sub")} """ from fastapi import Request + async def dependency(request: Request): from fastapi import HTTPException from .auth import extract_token_from_header + # Handle 'read_only' logic if self.access_mode != "authenticated": return None - + try: auth_header = request.headers.get("Authorization") token = extract_token_from_header(auth_header) @@ -730,20 +754,21 @@ async def dependency(request: Request): error_res = self._error_handler(e) raise HTTPException( status_code=error_res.status_code, - detail=json.loads(error_res.body.decode())["error"] + detail=json.loads(error_res.body.decode())["error"], ) + return dependency def require_token(self, methods=None): """Creates a FastAPI dependency to enforce token requirements on routes. - This method returns an async function designed to be injected into FastAPI + This method returns an async function designed to be injected into FastAPI endpoints using `Depends()`. It extracts the token, validates it, and returns - the claims. If the adapter's access mode is not set to 'authenticated' (e.g., + the claims. If the adapter's access mode is not set to 'authenticated' (e.g., 'read_only'), validation is bypassed. Args: - methods: Optional list of HTTP method names (e.g., ['POST', 'PUT']) to + methods: Optional list of HTTP method names (e.g., ['POST', 'PUT']) to protect. If None, all methods are protected. If the current request method is not in this list, authentication is bypassed. @@ -751,13 +776,13 @@ def require_token(self, methods=None): An asynchronous callable dependency that returns validated token claims. Raises: - fastapi.HTTPException: If token validation fails. The internal exception - is translated into a standard FastAPI HTTP error + fastapi.HTTPException: If token validation fails. The internal exception + is translated into a standard FastAPI HTTP error using the adapter's error handler. Example: from fastapi import Depends - + @app.get("/secure-data") async def get_secure_data( claims: dict = Depends(auth_adapter.require_token(methods=["POST"])) @@ -765,17 +790,19 @@ async def get_secure_data( return {"message": "Access granted", "user": claims.get("sub")} """ from fastapi import Request + async def dependency(request: Request): from fastapi import HTTPException from .auth import extract_token_from_header + # Handle 'read_only' logic if self.access_mode != "authenticated": return None - + if methods is not None and request.method not in methods: return None - + try: auth_header = request.headers.get("Authorization") token = extract_token_from_header(auth_header) @@ -787,69 +814,71 @@ async def dependency(request: Request): error_res = self._error_handler(e) raise HTTPException( status_code=error_res.status_code, - detail=json.loads(error_res.body.decode())["error"] + detail=json.loads(error_res.body.decode())["error"], ) + return dependency + class FlaskAuthAdapter(BaseAuthAdapter): def _initialize_oauth(self): """Initializes the Flask-based OAuth registry. - - Sets the internal response class to FastAPI's JSONResponse and returns + + Sets the internal response class to FastAPI's JSONResponse and returns the instantiated Authlib OAuth object. """ from authlib.integrations.flask_client import OAuth + return OAuth() def _error_handler(self, exc: Exception): """Formats an exception into a Flask JSON response. - + Args: exc: The exception caught during authentication or request processing. Returns: - A flask.Response object containing the resolved HTTP status code + A flask.Response object containing the resolved HTTP status code and formatted error payload. """ from flask import jsonify + msg, code = self._resolve_error(exc) - return jsonify({ - "error": { - "message": msg, - "details": str(exc) - } - }), code + return jsonify({"error": {"message": msg, "details": str(exc)}}), code def _token_response(self, token: dict, message: str = "Success"): """Formats successful token data into a Flask JSON response. - + Args: - token: A dictionary containing the token data (must include at least + token: A dictionary containing the token data (must include at least 'access_token' and 'refresh_token' keys). - message: An optional success message to include in the response payload. + message: An optional success message to include in the response payload. Defaults to "Success". Returns: - A flask.Response object with a 200 status code and the standardized + A flask.Response object with a 200 status code and the standardized token payload. """ from flask import jsonify - return jsonify({ - "message": message, - "token": { - "access_token": token.get("access_token"), - "refresh_token": token.get("refresh_token"), + + return jsonify( + { + "message": message, + "token": { + "access_token": token.get("access_token"), + "refresh_token": token.get("refresh_token"), + }, } - }), 200 + ), 200 def login(self, redirect_uri: str): """Initiates the OIDC login flow for Flask. - Uses the Flask Authlib client to generate a redirect response that + Uses the Flask Authlib client to generate a redirect response that sends the user to the authorization server. Args: - redirect_uri: The callback URL where the authorization server will + redirect_uri: The callback URL where the authorization server will redirect the user after authentication. Returns: @@ -867,12 +896,12 @@ def login(): def authorize(self): """Exchanges an authorization code for an access token in Flask. - This method should be called within the OIDC callback route. It - automatically handles the code exchange by accessing the global + This method should be called within the OIDC callback route. It + automatically handles the code exchange by accessing the global Flask request object. Returns: - A Flask Response object (JSON) containing the tokens on success, + A Flask Response object (JSON) containing the tokens on success, or a formatted error response on failure. Example: @@ -890,13 +919,13 @@ def refresh(self, request_json: dict): """Executes the synchronous token refresh request for Flask. Args: - request_json: A dictionary (the parsed JSON body) containing + request_json: A dictionary (the parsed JSON body) containing at least a 'refresh_token'. Returns: - A Flask Response object (JSON) containing the new tokens or + A Flask Response object (JSON) containing the new tokens or an error response if the exchange fails. - + Example: @app.route("/refresh", methods=["POST"]) def refresh_route(): @@ -904,16 +933,16 @@ def refresh_route(): """ refresh_token = request_json.get("refresh_token") if not refresh_token: - # We return the error handler result instead of raising + # We return the error handler result instead of raising # to match the Flask return-style flow. return self._error_handler(TokenExtractionError("Missing refresh_token")) - + scope = request_json.get("scope") try: kwargs = {"grant_type": "refresh_token", "refresh_token": refresh_token} if scope: kwargs["scope"] = scope - + new_tokens = self.dataone_oidc.fetch_access_token(**kwargs) return self._token_response(new_tokens, message="Token refresh successful") except Exception as e: @@ -922,16 +951,16 @@ def refresh_route(): def require_scope(self, required_scope: str, methods: None): """Creates a Flask decorator to enforce scope requirements on routes. - This method returns a decorator that extracts the Bearer token from the - 'Authorization' header, validates it, and injects the resulting claims - into the decorated function as the first argument. If the adapter is in - 'read_only' or 'open' mode, validation is bypassed and 'None' is passed + This method returns a decorator that extracts the Bearer token from the + 'Authorization' header, validates it, and injects the resulting claims + into the decorated function as the first argument. If the adapter is in + 'read_only' or 'open' mode, validation is bypassed and 'None' is passed for the claims. Args: - required_scope: The specific OAuth scope required to access the route + required_scope: The specific OAuth scope required to access the route (e.g., "read:data"). - methods: Optional list of HTTP method names (e.g., ['POST', 'PUT']) to + methods: Optional list of HTTP method names (e.g., ['POST', 'PUT']) to protect. If None, all methods are protected. If the current request method is not in this list, authentication is bypassed. @@ -944,39 +973,45 @@ def require_scope(self, required_scope: str, methods: None): def get_secure_data(claims): return {"message": "Access granted", "user": claims.get("sub")} """ + def decorator(f): @functools.wraps(f) def decorated(*args, **kwargs): from flask import request + if self.access_mode != "authenticated": return f(None, *args, **kwargs) if methods is not None and request.method not in methods: return f(None, *args, **kwargs) - + try: from flask import request + token = extract_token_from_header( - request.headers.get("Authorization")) + request.headers.get("Authorization") + ) claims = self.validate_and_extract_claims(token, required_scope) # Pass claims into the route return f(claims, *args, **kwargs) except Exception as e: return self._error_handler(e) + return decorated + return decorator def require_token(self, methods=None): """Creates a Flask decorator to enforce token authentication on routes. - This method returns a decorator that extracts the Bearer token from the - 'Authorization' header, validates it, and injects the resulting claims - into the decorated function as the first argument. If the adapter is in - 'read_only' or 'open' mode, validation is bypassed and 'None' is passed + This method returns a decorator that extracts the Bearer token from the + 'Authorization' header, validates it, and injects the resulting claims + into the decorated function as the first argument. If the adapter is in + 'read_only' or 'open' mode, validation is bypassed and 'None' is passed for the claims. Args: - methods: Optional list of HTTP method names (e.g., ['POST', 'PUT']) to + methods: Optional list of HTTP method names (e.g., ['POST', 'PUT']) to protect. If None, all methods are protected. If the current request method is not in this list, authentication is bypassed. @@ -990,10 +1025,12 @@ def handle_data(claims): user_id = claims.get("sub") if claims else "Anonymous" return {"message": "Success", "user": user_id} """ + def decorator(f): @functools.wraps(f) def decorated(*args, **kwargs): from flask import request + if self.access_mode != "authenticated": return f(None, *args, **kwargs) @@ -1003,12 +1040,16 @@ def decorated(*args, **kwargs): try: from flask import request + token = extract_token_from_header( - request.headers.get("Authorization")) + request.headers.get("Authorization") + ) claims = self.validate_and_extract_claims(token) # Pass claims into the route return f(claims, *args, **kwargs) except Exception as e: return self._error_handler(e) + return decorated + return decorator diff --git a/uv.lock b/uv.lock index fe2adfe..6f16442 100644 --- a/uv.lock +++ b/uv.lock @@ -241,7 +241,7 @@ wheels = [ [[package]] name = "dataone-auth" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "authlib" }, From 1a99641737e4cf172b525c3069dee4b88866b144 Mon Sep 17 00:00:00 2001 From: Matt Jones Date: Tue, 19 May 2026 14:25:32 -0800 Subject: [PATCH 46/63] Add method docstring, and remove unneeded pass statements. --- src/dataone/auth.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index b4458f7..c305413 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -44,8 +44,6 @@ class AuthError(Exception): """Base exception for dataone-auth.""" - pass - class MissingParameterError(AuthError): """Raised when a required request parameter is missing.""" @@ -54,23 +52,17 @@ class MissingParameterError(AuthError): class InsufficientScopeError(AuthError): """Raised when the token is valid but doesn't have the right scope.""" - pass - class TokenExtractionError(AuthError): """Raised when the Authorization header is missing or malformed.""" - pass - class InvalidTokenError(AuthError): """Raised when claims like iss or aud do not match expectations.""" - pass - class ConfigurationError(AuthError): - pass + """Raised when there is an issue with the configuration.""" ### Helpers From 8571ec51aaa85fdcd54c1354b9f4768363c2a2f9 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Tue, 19 May 2026 16:28:58 -0700 Subject: [PATCH 47/63] some small fixes from Rushi's review --- src/dataone/auth.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index c305413..406b487 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -286,8 +286,8 @@ class BaseAuthAdapter: ERROR_MAP = { TokenExtractionError: ("Invalid token or header", 401), - JoseError: ("Token decoding or signature verification failed", 401), # <- New - InvalidTokenError: ("Token validation failed", 401), # <- Now your custom error + JoseError: ("Token decoding or signature verification failed", 401), + InvalidTokenError: ("Token validation failed", 401), InvalidClientError: ("OIDC client authentication failed", 401), InvalidGrantError: ("Invalid or expired refresh token", 401), OAuthError: ("Authorization failed", 401), @@ -297,6 +297,7 @@ class BaseAuthAdapter: MissingParameterError: ("Missing required parameter", 400), ValueError: ("OIDC provider configuration error", 500), RequestException: ("Failed to fetch OIDC provider keys", 502), + InsufficientScopeError: ("Insufficient scope", 403) } def __init__(self, secrets, scopes): @@ -692,7 +693,7 @@ async def refresh(request: Request): except Exception as e: return self._error_handler(e) - def require_scope(self, required_scope: str, methods=None): + def require_scope(self, required_scope: str, methods = None): """Creates a FastAPI dependency to enforce scope requirements on routes. This method returns an async function designed to be injected into FastAPI @@ -734,6 +735,9 @@ async def dependency(request: Request): # Handle 'read_only' logic if self.access_mode != "authenticated": return None + + if methods is not None and request.method not in methods: + return None try: auth_header = request.headers.get("Authorization") @@ -940,7 +944,7 @@ def refresh_route(): except Exception as e: return self._error_handler(e) - def require_scope(self, required_scope: str, methods: None): + def require_scope(self, required_scope: str, methods = None): """Creates a Flask decorator to enforce scope requirements on routes. This method returns a decorator that extracts the Bearer token from the From 438b1bfb6f870ad6617bdc00f98719801baf093b Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Thu, 28 May 2026 13:23:17 -0700 Subject: [PATCH 48/63] loosen python requirements for ogdc-runner --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5159abe..b6385e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ authors = [ { name = "Jeanette Clark", email = "jclark@nceas.ucsb.edu" }, { name = "Matthew B. Jones", email = "jones@nceas.ucsb.edu" } ] -requires-python = ">=3.13" +requires-python = ">=3.11" dependencies = [ "authlib>=1.7.2", "flask>=3.1.3", From 3ecfabaf6792b8a3375e839acc4b83731b6a6b03 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Thu, 28 May 2026 13:23:44 -0700 Subject: [PATCH 49/63] update lock file --- uv.lock | 135 +++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 134 insertions(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 6f16442..b2b5234 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.13" +requires-python = ">=3.11" [[package]] name = "annotated-doc" @@ -26,6 +26,7 @@ version = "4.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } wheels = [ @@ -72,6 +73,31 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, @@ -114,6 +140,38 @@ 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/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" }, @@ -237,6 +295,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, + { url = "https://files.pythonhosted.org/packages/be/d2/024b5e06be9d44cb021fb0e1a03d34d63989cf56a0fe62f3dfbab695b9b4/cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855", size = 3950391, upload-time = "2026-05-04T22:59:17.415Z" }, + { url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" }, + { url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" }, ] [[package]] @@ -420,6 +484,28 @@ version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, @@ -517,6 +603,36 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, @@ -562,6 +678,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] [[package]] @@ -635,6 +767,7 @@ version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } wheels = [ From c14b63d27ee6f3d994579a2d97ffff611a2b2559 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Thu, 28 May 2026 13:28:18 -0700 Subject: [PATCH 50/63] make flask deps optional --- pyproject.toml | 3 +-- uv.lock | 6 ++---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b6385e8..9e804ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,16 +11,15 @@ authors = [ requires-python = ">=3.11" dependencies = [ "authlib>=1.7.2", - "flask>=3.1.3", "httpx>=0.28.1", "joserfc>=1.6.5", "requests>=2.33.1", - "werkzeug>=3.1.8", ] [project.optional-dependencies] flask = [ "flask>=3.1.3", + "werkzeug>=3.1.8", ] fastapi = [ "fastapi>=0.136.1", diff --git a/uv.lock b/uv.lock index b2b5234..6ad1675 100644 --- a/uv.lock +++ b/uv.lock @@ -309,11 +309,9 @@ version = "0.2.0" source = { editable = "." } dependencies = [ { name = "authlib" }, - { name = "flask" }, { name = "httpx" }, { name = "joserfc" }, { name = "requests" }, - { name = "werkzeug" }, ] [package.optional-dependencies] @@ -324,6 +322,7 @@ fastapi = [ ] flask = [ { name = "flask" }, + { name = "werkzeug" }, ] starlette = [ { name = "httpx" }, @@ -339,7 +338,6 @@ dev = [ requires-dist = [ { name = "authlib", specifier = ">=1.7.2" }, { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.136.1" }, - { name = "flask", specifier = ">=3.1.3" }, { name = "flask", marker = "extra == 'flask'", specifier = ">=3.1.3" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", marker = "extra == 'fastapi'", specifier = ">=0.28.1" }, @@ -347,7 +345,7 @@ requires-dist = [ { name = "joserfc", specifier = ">=1.6.5" }, { name = "requests", specifier = ">=2.33.1" }, { name = "starlette", marker = "extra == 'fastapi'", specifier = ">=1.0.0" }, - { name = "werkzeug", specifier = ">=3.1.8" }, + { name = "werkzeug", marker = "extra == 'flask'", specifier = ">=3.1.8" }, ] provides-extras = ["flask", "fastapi", "starlette"] From 5d2b43130df3f839dea3c7d76f757673addf7eac Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Thu, 28 May 2026 13:23:17 -0700 Subject: [PATCH 51/63] loosen python requirements for ogdc-runner --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5159abe..b6385e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ authors = [ { name = "Jeanette Clark", email = "jclark@nceas.ucsb.edu" }, { name = "Matthew B. Jones", email = "jones@nceas.ucsb.edu" } ] -requires-python = ">=3.13" +requires-python = ">=3.11" dependencies = [ "authlib>=1.7.2", "flask>=3.1.3", From 011855d133acefd045178ab7d662b618069de2e2 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Thu, 28 May 2026 13:23:44 -0700 Subject: [PATCH 52/63] update lock file --- uv.lock | 135 +++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 134 insertions(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 6f16442..b2b5234 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.13" +requires-python = ">=3.11" [[package]] name = "annotated-doc" @@ -26,6 +26,7 @@ version = "4.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } wheels = [ @@ -72,6 +73,31 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, @@ -114,6 +140,38 @@ 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/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" }, @@ -237,6 +295,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, + { url = "https://files.pythonhosted.org/packages/be/d2/024b5e06be9d44cb021fb0e1a03d34d63989cf56a0fe62f3dfbab695b9b4/cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855", size = 3950391, upload-time = "2026-05-04T22:59:17.415Z" }, + { url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" }, + { url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" }, ] [[package]] @@ -420,6 +484,28 @@ version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, @@ -517,6 +603,36 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, @@ -562,6 +678,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] [[package]] @@ -635,6 +767,7 @@ version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } wheels = [ From a63e136d89de572fd1737d8c745ea65b7e2cd635 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Thu, 28 May 2026 13:28:18 -0700 Subject: [PATCH 53/63] make flask deps optional --- pyproject.toml | 3 +-- uv.lock | 6 ++---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b6385e8..9e804ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,16 +11,15 @@ authors = [ requires-python = ">=3.11" dependencies = [ "authlib>=1.7.2", - "flask>=3.1.3", "httpx>=0.28.1", "joserfc>=1.6.5", "requests>=2.33.1", - "werkzeug>=3.1.8", ] [project.optional-dependencies] flask = [ "flask>=3.1.3", + "werkzeug>=3.1.8", ] fastapi = [ "fastapi>=0.136.1", diff --git a/uv.lock b/uv.lock index b2b5234..6ad1675 100644 --- a/uv.lock +++ b/uv.lock @@ -309,11 +309,9 @@ version = "0.2.0" source = { editable = "." } dependencies = [ { name = "authlib" }, - { name = "flask" }, { name = "httpx" }, { name = "joserfc" }, { name = "requests" }, - { name = "werkzeug" }, ] [package.optional-dependencies] @@ -324,6 +322,7 @@ fastapi = [ ] flask = [ { name = "flask" }, + { name = "werkzeug" }, ] starlette = [ { name = "httpx" }, @@ -339,7 +338,6 @@ dev = [ requires-dist = [ { name = "authlib", specifier = ">=1.7.2" }, { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.136.1" }, - { name = "flask", specifier = ">=3.1.3" }, { name = "flask", marker = "extra == 'flask'", specifier = ">=3.1.3" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", marker = "extra == 'fastapi'", specifier = ">=0.28.1" }, @@ -347,7 +345,7 @@ requires-dist = [ { name = "joserfc", specifier = ">=1.6.5" }, { name = "requests", specifier = ">=2.33.1" }, { name = "starlette", marker = "extra == 'fastapi'", specifier = ">=1.0.0" }, - { name = "werkzeug", specifier = ">=3.1.8" }, + { name = "werkzeug", marker = "extra == 'flask'", specifier = ">=3.1.8" }, ] provides-extras = ["flask", "fastapi", "starlette"] From f966782a3f8d4285be6662cef1bfb7cd87ed725f Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 29 May 2026 07:57:20 -0700 Subject: [PATCH 54/63] add token helpers --- src/dataone/auth.py | 71 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 406b487..b88ab93 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -11,10 +11,13 @@ web frameworks without hard dependencies on any particular framework. """ +import base64 +import datetime as dt import functools import json import os import re +from typing import Any import httpx import requests @@ -217,6 +220,74 @@ def decode_claims(token_str, jwks, client_id, issuer): return claims +def is_token_valid(token: str | None, buffer_minutes: int = 1) -> bool: + """Check if a JWT token unexpired. + + Args: + token: The raw JWT string to validate. + buffer_minutes: A safety margin added to the current time to account for network + lag. + + Returns: + True if the token is valid and unexpired, False (or None if parsing fails). + """ + if not token: + return False + try: + parts = token.split(".") + if len(parts) < 2: + return None + payload = parts[1] + payload += "=" * ((4 - len(payload) % 4) % 4) + exp = json.loads(base64.urlsafe_b64decode(payload).decode("utf-8")).get("exp") + except Exception: + return None + + if not exp: + return False + expiry_time = dt.datetime.fromtimestamp(exp, tz=dt.UTC) + e = expiry_time > (dt.datetime.now(dt.UTC) + dt.timedelta(minutes=buffer_minutes)) + return e + +def parse_tokens_dict(tokens: str | dict[str, Any]) -> dict[str, str]: + """Parse and normalize a raw token payload into a validated dictionary. + + Args: + tokens: A raw JSON string or dictionary containing OIDC tokens. + + Returns: + A dictionary containing verified 'access_token' and/or 'refresh_token' keys. + + Raises: + ValueError: If the input is malformed, missing key fields, or contains empty + strings. + """ + if isinstance(tokens, str): + try: + tokens = json.loads(tokens) + except json.JSONDecodeError as e: + raise ValueError(f"'tokens' could not be parsed as JSON: {e}") + + if not isinstance(tokens, dict): + raise ValueError("'tokens' must be a dictionary or a JSON string") + + if "token" in tokens and isinstance(tokens["token"], dict): + tokens = tokens["token"] + + if not any(key in tokens for key in ("access_token", "refresh_token")): + raise ValueError( + "'tokens' must contain at least one of 'access_token' or 'refresh_token'" + ) + + normalized: dict[str, str] = {} + for key in ("access_token", "refresh_token"): + if key in tokens and tokens[key] is not None: + val = tokens[key] + if not isinstance(val, str) or len(val.strip()) == 0: + raise ValueError(f"'{key}' must be a non-empty string") + normalized[key] = val + + return normalized ### Factory From 14fec706f890df3b5a869bdf8a613aeae6d710c8 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 29 May 2026 11:56:50 -0700 Subject: [PATCH 55/63] add refresh tokens method --- src/dataone/auth.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index b88ab93..929d35b 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -241,7 +241,7 @@ def is_token_valid(token: str | None, buffer_minutes: int = 1) -> bool: payload += "=" * ((4 - len(payload) % 4) % 4) exp = json.loads(base64.urlsafe_b64decode(payload).decode("utf-8")).get("exp") except Exception: - return None + return False if not exp: return False @@ -289,6 +289,28 @@ def parse_tokens_dict(tokens: str | dict[str, Any]) -> dict[str, str]: return normalized +def refresh_tokens(refresh_url: str, + refresh_token: str, + session: requests.Session | None = None) -> dict: + """Exchange a refresh token for a new token payload. + + Args: + refresh_url: The API endpoint URL used for token renewal. + refresh_token: The OIDC refresh token string. + session: An optional requests session to use for the network request. + + Returns: + A dictionary containing the fresh token payload. + + Raises: + requests.exceptions.HTTPError: If the server returns an unsuccessful status + code. + """ + client = session or requests.Session() + response = client.post(refresh_url, json={"refresh_token": refresh_token}) + response.raise_for_status() + return response.json() + ### Factory From 33906f39a09e23740e230a0b06caaf984063023f Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 29 May 2026 12:25:47 -0700 Subject: [PATCH 56/63] add tests, return False for malformed tokens --- src/dataone/auth.py | 4 ++-- tests/test_auth.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 929d35b..818978d 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -229,14 +229,14 @@ def is_token_valid(token: str | None, buffer_minutes: int = 1) -> bool: lag. Returns: - True if the token is valid and unexpired, False (or None if parsing fails). + True if the token is valid and unexpired, False otherwise. """ if not token: return False try: parts = token.split(".") if len(parts) < 2: - return None + return False payload = parts[1] payload += "=" * ((4 - len(payload) % 4) % 4) exp = json.loads(base64.urlsafe_b64decode(payload).decode("utf-8")).get("exp") diff --git a/tests/test_auth.py b/tests/test_auth.py index e832271..533884a 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -11,6 +11,8 @@ decode_claims, extract_orcid, extract_token_from_header, + is_token_valid, + parse_tokens_dict, ) @@ -78,6 +80,39 @@ def test_extract_token_too_long(): match="Token exceeds maximum allowed length"): extract_token_from_header(f"Bearer {long_token}") +def test_token_valid_expired_malformed(): + """Test that an expired token is recognized as such.""" + token = ('eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJ3aUFQTnZuc1I1RS1WLTVua' + 'G5UclRvcTUyeTBnT0gwWXd2dmx3VW9BVWJVIn0.eyJleHAiOjE3Njk2NzE3MjgsImlhdCI6MTc2OTY2ODE' + 'yOCwiYXV0aF90aW1lIjoxNzY5NjYxNzA0LCJqdGkiOiJvbnJ0cnQ6YWQ2YzI2YjItNWY2Ny1jZTE4LTc3N' + 'DgtY2JjYjM0OWZhNTM1IiwiaXNzIjoiaHR0cHM6Ly9hdXRoLnRlc3QuZGF0YW9uZS5vcmcvcmVhbG1zL2R' + 'hdGFvbmUiLCJzdWIiOiJmYzM5NGIzMi05ZmY3LTQ4NWQtODJmMy03ZGI2ODI0YzhjYjUiLCJ0eXAiOiJCZ' + 'WFyZXIiLCJhenAiOiJvZ2RjIiwic2lkIjoiLTN1Rm5SWjZKR0cxaWxYSmZYbjNQTHZuIiwiYWNyIjoiMSI' + 'sImFsbG93ZWQtb3JpZ2lucyI6WyJodHRwczovL2FwaS50ZXN0LmRhdGFvbmUub3JnIl0sInNjb3BlIjoib' + '3BlbmlkIHByb2ZpbGUgZW1haWwgdmVnYmFuazpjb250cmlidXRvciB2ZWdiYW5rOmFkbWluIiwiZW1haWx' + 'fdmVyaWZpZWQiOmZhbHNlLCJ2ZXJpZmllZCI6dHJ1ZSwibmFtZSI6Ik1hdHRoZXcgSm9uZXMiLCJwcmVmZ' + 'XJyZWRfdXNlcm5hbWUiOiJtZXRhbWF0dGoiLCJnaXZlbl9uYW1lIjoiTWF0dGhldyIsImZhbWlseV9uYW1' + 'lIjoiSm9uZXMiLCJlbWFpbCI6ImpvbmVzQG5jZWFzLnVjc2IuZWR1In0.guUa1eTiTpcPkQqIUNHy5tcrP' + 'oy5PI4QIjyd0ZKsPMCb3u19OKxlMvFX2nOfncX2_O7KK-u7f_bNGo9z0ftr0FCSWC9ZEvDtRyHdK-60_3P' + 'izvgq8SPsRP9363-t39RjClo6t0Dd5N2P6L2Blcylhxes_cmS2fT8xQwZIBmvUCKXoafBCvdbKHU5hLUCx' + 'OEbrjE1ZBWejN2dlgglA1dgU-HOZxHu-2m76GWWui1nW7mOHNOkgFLxFjJ7HLNuxleh_T1lciYBKTjXe8M' + 'fsR1hABm1u15ABVfE96VZkMCWxZMJanffxUaEa73rEvPSBhgxCmuB_6kNHu0FhdHrWdP4uA' + ) + assert is_token_valid(token) is False + assert is_token_valid("foo") is False + +def test_parse_tokens_success(): + """Test that a raw string with token and refresh token is parsed correctly.""" + + token_str = ('{"message":"Success","token":{"access_token":"access.token.text",' + '"refresh_token":"refresh.token.text"}}') + token_dict = parse_tokens_dict(token_str) + access = token_dict['access_token'] + refresh = token_dict['refresh_token'] + + assert access == 'access.token.text' + assert refresh == 'refresh.token.text' + def test_decode_claims_success(): # generate rsa key raw_key = RSAKey.generate_key(2048) From ce30de43ceaf5a641e3ff62699c465bc1e92e9ea Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Wed, 17 Jun 2026 14:37:24 -0700 Subject: [PATCH 57/63] make logic for open and read only modes work properly --- src/dataone/auth.py | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 818978d..70888df 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -825,12 +825,18 @@ async def dependency(request: Request): from .auth import extract_token_from_header - # Handle 'read_only' logic - if self.access_mode != "authenticated": - return None + # Handle 'open' logic + if self.access_mode == ACCESS_MODE_OPEN: + return {} - if methods is not None and request.method not in methods: - return None + # Handle 'read only' logic + if self.access_mode == ACCESS_MODE_READ_ONLY: + if request.method in ["POST", "PUT", "DELETE", "PATCH"]: + raise HTTPException( + status_code=403, + detail="This API is currently in read-only mode." + ) + return {} try: auth_header = request.headers.get("Authorization") @@ -885,12 +891,18 @@ async def dependency(request: Request): from .auth import extract_token_from_header - # Handle 'read_only' logic - if self.access_mode != "authenticated": - return None - - if methods is not None and request.method not in methods: - return None + # Handle 'open' logic + if self.access_mode == ACCESS_MODE_OPEN: + return {} + + # Handle 'read only' logic + if self.access_mode == ACCESS_MODE_READ_ONLY: + if request.method in ["POST", "PUT", "DELETE", "PATCH"]: + raise HTTPException( + status_code=403, + detail="This API is currently in read-only mode." + ) + return {} try: auth_header = request.headers.get("Authorization") From 363a094dde42fd9531005047e89f1f442150797f Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Thu, 18 Jun 2026 10:37:30 -0700 Subject: [PATCH 58/63] fix argument name --- tests/test_auth.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_auth.py b/tests/test_auth.py index 533884a..dffe77c 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -118,12 +118,12 @@ def test_decode_claims_success(): raw_key = RSAKey.generate_key(2048) # export to dict and strictly set a string 'kid' - private_jwk = raw_key.as_dict(is_private=True) + private_jwk = raw_key.as_dict(private=True) private_jwk['kid'] = 'test-key-id-1' # re-import the key so it officially has the kid, and create the public JWKS key = RSAKey.import_key(private_jwk) - public_jwk = KeySet.import_key_set({"keys": [key.as_dict(is_private=False)]}) + public_jwk = KeySet.import_key_set({"keys": [key.as_dict(private=False)]}) # setup mock claims/headers header = {'alg': 'RS256', 'kid': 'test-key-id-1'} @@ -153,11 +153,11 @@ def test_decode_claims_success(): def test_decode_claims_invalid_issuer(): raw_key = RSAKey.generate_key(2048) - private_jwk = raw_key.as_dict(is_private=True) + private_jwk = raw_key.as_dict(private=True) private_jwk['kid'] = 'test-key-id-2' key = RSAKey.import_key(private_jwk) - public_jwk = KeySet.import_key_set({"keys": [key.as_dict(is_private=False)]}) + public_jwk = KeySet.import_key_set({"keys": [key.as_dict(private=False)]}) # token has 'wrong-issuer' header = {'alg': 'RS256', 'kid': 'test-key-id-2'} From 75b9e71533cffa124f625315381195112c3c036b Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Thu, 18 Jun 2026 10:38:05 -0700 Subject: [PATCH 59/63] add nox and implement what mypy picked up --- noxfile.py | 40 ++++ pyproject.toml | 8 + src/dataone/auth.py | 24 +- uv.lock | 526 +++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 584 insertions(+), 14 deletions(-) create mode 100644 noxfile.py diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 0000000..566bbc0 --- /dev/null +++ b/noxfile.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import argparse +import os +import shutil +from pathlib import Path + +import nox + +DIR = Path(__file__).parent.resolve() + +nox.needs_version = ">=2024.3.2" +# Typing `nox` with no arguments will automatically run these two sessions +nox.options.sessions = ["typecheck", "tests"] +nox.options.default_venv_backend = "uv|virtualenv" + +if os.environ.get("ENVIRONMENT") == "dev": + # Use existing venvs where possible in dev + nox.options.reuse_existing_virtualenvs = True +else: + # All other envs should have the nox venvs recreated. + nox.options.reuse_existing_virtualenvs = False + +nox.options.stop_on_first_error = True + + +@nox.session(python="3.11") +def typecheck(session: nox.Session) -> None: + """Run typechecker (mypy).""" + session.install("mypy", ".[flask,fastapi,starlette]") + run_args = session.posargs if session.posargs else ["src"] + session.run("mypy", *run_args) + + +@nox.session(python="3.11") +def tests(session: nox.Session) -> None: + """Run all tests.""" + session.install("pytest", ".") + run_args = session.posargs if session.posargs else ["tests"] + session.run("pytest", *run_args) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 9e804ab..99d0a30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,8 @@ build-backend = "hatchling.build" dev = [ "pytest>=9.0.3", "ruff>=0.15.12", + "nox", + "mypy", ] [tool.ruff] @@ -59,3 +61,9 @@ python_files = ["test_*.py", "*_test.py"] python_classes = ["Test*"] python_functions = ["test_*"] addopts = "-v" + +[[tool.mypy.overrides]] +module = [ + "authlib.*", +] +ignore_missing_imports = true diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 70888df..21524a0 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -100,7 +100,7 @@ def load_client_secrets(filepath: str | None = None) -> dict: raise ConfigurationError(f"OIDC secrets file at {resolved} is not valid JSON") -def extract_token_from_header(auth_header: str): +def extract_token_from_header(auth_header: str | None): """Extracts and validates a Bearer token from an auth header string. Args: @@ -450,7 +450,7 @@ def _resolve_error(self, exc: Exception): return "Internal authentication error", 500 - def _verify_scope(self, claims: dict, required_scope: str | None): + def _verify_scope(self, claims: dict, required_scope: str | None = None): """Internal helper to check if the required scope exists in claims.""" if not required_scope: return @@ -526,7 +526,9 @@ def _decode_and_validate_token(self, token_str: str): return decode_claims(token_str, jwks, client_id, issuer) - def validate_and_extract_claims(self, token_str: str, required_scope: str = None): + def validate_and_extract_claims(self, + token_str: str, + required_scope: str | None = None): """Validate a token string and optionally check required scope. Args: @@ -548,7 +550,7 @@ def validate_and_extract_claims(self, token_str: str, required_scope: str = None return claims - def login(self, redirect_uri: str, request=None): + def login(self, redirect_uri: str, request=None) -> Any: """This is implemented by subclasses.""" raise NotImplementedError @@ -675,9 +677,9 @@ async def _decode_and_validate_token(self, token_str: str): return decode_claims(token_str, jwks, client_id, issuer) - async def validate_and_extract_claims( - self, token_str: str, required_scope: str = None - ): + async def validate_and_extract_claims(self, + token_str: str, + required_scope: str | None = None): """Asynchronously decodes and validates a JWT using the provider's JWKS. This overrides the base method to support Starlette/FastAPI's asynchronous @@ -697,7 +699,7 @@ async def validate_and_extract_claims( return claims - async def login(self, request, redirect_uri: str): + async def login(self, redirect_uri: str, request: Any = None) -> Any: """Asynchronously initiates the OIDC login flow. Uses the Starlette OAuth client to generate a redirect response that @@ -823,8 +825,6 @@ async def get_secure_data( async def dependency(request: Request): from fastapi import HTTPException - from .auth import extract_token_from_header - # Handle 'open' logic if self.access_mode == ACCESS_MODE_OPEN: return {} @@ -889,8 +889,6 @@ async def get_secure_data( async def dependency(request: Request): from fastapi import HTTPException - from .auth import extract_token_from_header - # Handle 'open' logic if self.access_mode == ACCESS_MODE_OPEN: return {} @@ -972,7 +970,7 @@ def _token_response(self, token: dict, message: str = "Success"): } ), 200 - def login(self, redirect_uri: str): + def login(self, redirect_uri: str, request: Any = None) -> Any: """Initiates the OIDC login flow for Flask. Uses the Flask Authlib client to generate a redirect response that diff --git a/uv.lock b/uv.lock index 6ad1675..9cc666f 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,20 @@ version = 1 revision = 3 requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.12' and python_full_version < '3.15'", + "python_full_version < '3.12'", +] + +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, +] [[package]] name = "annotated-doc" @@ -33,6 +47,64 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] +[[package]] +name = "argcomplete" +version = "3.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/61/0b9ae6399dd4a58d8c1b1dc5a27d6f2808023d0b5dd3104bb99f45a33ff6/argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c", size = 73754, upload-time = "2025-10-20T03:33:34.741Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size = 1183520, upload-time = "2026-05-17T17:47:30.831Z" }, + { url = "https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size = 1175779, upload-time = "2026-05-17T17:47:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size = 1233750, upload-time = "2026-05-17T17:47:34.731Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size = 1235942, upload-time = "2026-05-17T17:47:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size = 1442517, upload-time = "2026-05-17T17:47:38.17Z" }, + { url = "https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size = 1254081, upload-time = "2026-05-17T17:47:39.826Z" }, + { url = "https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size = 1259910, upload-time = "2026-05-17T17:47:41.369Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size = 1250678, upload-time = "2026-05-17T17:47:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size = 1301603, upload-time = "2026-05-17T17:47:46.256Z" }, + { url = "https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size = 1410332, upload-time = "2026-05-17T17:47:47.899Z" }, + { url = "https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size = 1509979, upload-time = "2026-05-17T17:47:50.942Z" }, + { url = "https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size = 1505002, upload-time = "2026-05-17T17:47:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size = 1456231, upload-time = "2026-05-17T17:47:56.311Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size = 1058668, upload-time = "2026-05-17T17:47:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size = 1101075, upload-time = "2026-05-17T17:48:00.35Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size = 1075347, upload-time = "2026-05-17T17:48:01.753Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" }, + { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" }, + { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" }, + { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" }, + { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" }, + { url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" }, + { url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" }, + { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "authlib" version = "1.7.2" @@ -46,6 +118,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, ] +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + [[package]] name = "blinker" version = "1.9.0" @@ -244,6 +325,18 @@ 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 = "colorlog" +version = "6.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/61/f083b5ac52e505dfc1c624eafbf8c7589a0d7f32daa398d2e7590efa5fda/colorlog-6.10.1.tar.gz", hash = "sha256:eb4ae5cb65fe7fec7773c2306061a8e63e02efc2c72eba9d27b0fa23c94f1321", size = 17162, upload-time = "2025-10-16T16:14:11.978Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/c1/e419ef3723a074172b68aaa89c9f3de486ed4c2399e2dbd8113a4fdcaf9e/colorlog-6.10.1-py3-none-any.whl", hash = "sha256:2d7e8348291948af66122cff006c9f8da6255d224e7cf8e37d8de2df3bad8c9c", size = 11743, upload-time = "2025-10-16T16:14:10.512Z" }, +] + [[package]] name = "cryptography" version = "48.0.0" @@ -315,6 +408,10 @@ dependencies = [ ] [package.optional-dependencies] +docs = [ + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] fastapi = [ { name = "fastapi" }, { name = "httpx" }, @@ -330,8 +427,13 @@ starlette = [ [package.dev-dependencies] dev = [ + { name = "mypy" }, + { name = "nox" }, { name = "pytest" }, { name = "ruff" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinx-rtd-theme" }, ] [package.metadata] @@ -344,15 +446,50 @@ requires-dist = [ { name = "httpx", marker = "extra == 'starlette'", specifier = ">=0.28.1" }, { name = "joserfc", specifier = ">=1.6.5" }, { name = "requests", specifier = ">=2.33.1" }, + { name = "sphinx", marker = "extra == 'docs'", specifier = ">=7.0.0" }, { name = "starlette", marker = "extra == 'fastapi'", specifier = ">=1.0.0" }, { name = "werkzeug", marker = "extra == 'flask'", specifier = ">=3.1.8" }, ] -provides-extras = ["flask", "fastapi", "starlette"] +provides-extras = ["flask", "fastapi", "starlette", "docs"] [package.metadata.requires-dev] dev = [ + { name = "mypy" }, + { name = "nox" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "ruff", specifier = ">=0.15.12" }, + { name = "sphinx", specifier = ">=9.0.4" }, + { name = "sphinx-rtd-theme", specifier = ">=3.1.0" }, +] + +[[package]] +name = "dependency-groups" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/55/f054de99871e7beb81935dea8a10b90cd5ce42122b1c3081d5282fdb3621/dependency_groups-1.3.1.tar.gz", hash = "sha256:78078301090517fd938c19f64a53ce98c32834dfe0dee6b88004a569a6adfefd", size = 10093, upload-time = "2025-05-02T00:34:29.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/c7/d1ec24fb280caa5a79b6b950db565dab30210a66259d17d5bb2b3a9f878d/dependency_groups-1.3.1-py3-none-any.whl", hash = "sha256:51aeaa0dfad72430fcfb7bcdbefbd75f3792e5919563077f30bc0d73f4493030", size = 8664, upload-time = "2025-05-02T00:34:27.085Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, ] [[package]] @@ -371,6 +508,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, ] +[[package]] +name = "filelock" +version = "3.29.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/dc/be6cbe99670cd6e4ad387123647cb08e0c32975e223f82551e914c5568a6/filelock-3.29.4.tar.gz", hash = "sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a", size = 63028, upload-time = "2026-06-13T16:12:00.744Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767", size = 42757, upload-time = "2026-06-13T16:11:59.582Z" }, +] + [[package]] name = "flask" version = "3.1.3" @@ -425,6 +571,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "humanize" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/66/a3921783d54be8a6870ac4ccffcd15c4dc0dd7fcce51c6d63b8c63935276/humanize-4.15.0.tar.gz", hash = "sha256:1dd098483eb1c7ee8e32eb2e99ad1910baefa4b75c3aff3a82f4d78688993b10", size = 83599, upload-time = "2025-12-20T20:16:13.19Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/7b/bca5613a0c3b542420cf92bd5e5fb8ebd5435ce1011a091f66bb7693285e/humanize-4.15.0-py3-none-any.whl", hash = "sha256:b1186eb9f5a9749cd9cb8565aee77919dd7c8d076161cf44d70e59e3301e1769", size = 132203, upload-time = "2025-12-20T20:16:11.67Z" }, +] + [[package]] name = "idna" version = "3.13" @@ -434,6 +589,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, ] +[[package]] +name = "imagesize" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -476,6 +640,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/3b/ad1cb22e75c963b1f07c8a2329bf47227ce7e4361df5eb2fb101b2ce33ef/joserfc-1.6.5-py3-none-any.whl", hash = "sha256:e9878a0f8243fe7b95e11fdda81374ca9f7a689e302751579d3dfdeec559675e", size = 70464, upload-time = "2026-05-06T04:58:11.668Z" }, ] +[[package]] +name = "librt" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size = 141092, upload-time = "2026-05-10T18:15:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size = 142035, upload-time = "2026-05-10T18:15:36.242Z" }, + { url = "https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size = 475022, upload-time = "2026-05-10T18:15:37.56Z" }, + { url = "https://files.pythonhosted.org/packages/de/f3/aa81523e45184c6ec23dc7f63263362ec55f80a09d424c012359ecbe7e35/librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b", size = 467273, upload-time = "2026-05-10T18:15:39.182Z" }, + { url = "https://files.pythonhosted.org/packages/6b/6f/59c74b560ca8853834d5501d589c8a2519f4184f273a085ffd0f37a1cc47/librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89", size = 497083, upload-time = "2026-05-10T18:15:40.634Z" }, + { url = "https://files.pythonhosted.org/packages/fe/7b/5aa4d2c9600a719401160bf7055417df0b2a47439b9d88286ce45e56b65f/librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc", size = 489139, upload-time = "2026-05-10T18:15:41.934Z" }, + { url = "https://files.pythonhosted.org/packages/d6/31/9143803d7da6856a69153785768c4936864430eec0fd9461c3ea527d9922/librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5", size = 508442, upload-time = "2026-05-10T18:15:43.206Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5a/bce08184488426bda4ccc2c4964ac048c8f68ae89bd7120082eef4233cfd/librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7", size = 514230, upload-time = "2026-05-10T18:15:44.761Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/bb5e213d254b7505a0e658da199d8ab719086632ce09eef311ab27976523/librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d", size = 494231, upload-time = "2026-05-10T18:15:46.308Z" }, + { url = "https://files.pythonhosted.org/packages/9d/fb/541cdad5b1ab1300398c74c4c9a497b88e5074c21b1244c8f49731d3a284/librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412", size = 537585, upload-time = "2026-05-10T18:15:47.629Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f2/464bb69295c320cb06bddb4f14a4ec67934ee14b2bffb12b19fb7ab287ba/librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d", size = 100509, upload-time = "2026-05-10T18:15:49.157Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e7/a17ee1788f9e4fbf548c19f4afa07c92089b9e24fef6cb2410863781ef4c/librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73", size = 118628, upload-time = "2026-05-10T18:15:50.345Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/6c766214f9f9903bcfcfbef97d807af8d8f5aa3502d247858ab17582d212/librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c", size = 103122, upload-time = "2026-05-10T18:15:52.068Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, + { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, + { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, + { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, + { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, + { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, + { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, + { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, + { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, + { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" }, + { url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" }, + { url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" }, + { url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" }, + { url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" }, + { url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" }, + { url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" }, + { url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" }, + { url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" }, + { url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" }, + { url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" }, + { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -550,6 +787,84 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "mypy" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", size = 14691685, upload-time = "2026-05-11T18:33:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", size = 13555165, upload-time = "2026-05-11T18:32:16.107Z" }, + { url = "https://files.pythonhosted.org/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", size = 13994376, upload-time = "2026-05-11T18:32:39.256Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/285946c33bce716e082c11dfeee9ee196eaf1f5042efb3581a31f9f205e4/mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398", size = 14864618, upload-time = "2026-05-11T18:34:49.765Z" }, + { url = "https://files.pythonhosted.org/packages/2b/83/82397f48af6c27e295d57979ded8490c9829040152cf7571b2f026aeb9a0/mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563", size = 15102063, upload-time = "2026-05-11T18:34:05.855Z" }, + { url = "https://files.pythonhosted.org/packages/40/68/b02dec39057b88eb03dc0aa854732e26e8361f34f9d0e20c7614967d1eba/mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389", size = 11060564, upload-time = "2026-05-11T18:35:36.494Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a8/ea3dcbef31f99b634f2ee23bb0321cbc8c1b388b76a861eb849f13c347dc/mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666", size = 9966983, upload-time = "2026-05-11T18:37:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" }, + { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" }, + { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, + { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, + { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" }, + { url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" }, + { url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" }, + { url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" }, + { url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" }, + { url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nox" +version = "2026.4.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argcomplete" }, + { name = "attrs" }, + { name = "colorlog" }, + { name = "dependency-groups" }, + { name = "humanize" }, + { name = "packaging" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/6b/e672c862a43cfca704d32359221fa3780226daa1e5db5dfc401bcc8be9c9/nox-2026.4.10.tar.gz", hash = "sha256:2d0af5374f3f37a295428c927d1b04a8182aa01762897d172446dda2f1ce9692", size = 4034839, upload-time = "2026-04-10T17:42:42.209Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/95/4df134a100b5a9a12378d5301b934366686ef6fbdaffcd21211d5654970e/nox-2026.4.10-py3-none-any.whl", hash = "sha256:082c117627590d9b90aa21f86df89b310b07c5842539524203bcb3c719f116c1", size = 75536, upload-time = "2026-04-10T17:42:40.664Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -559,6 +874,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -719,6 +1052,19 @@ 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 = "python-discovery" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/1a/cbbaf13b730abb0a16b964d984e19f2fe520c21a4dc664051359a3f5a9e7/python_discovery-1.4.2.tar.gz", hash = "sha256:8f3746c4b4968d22afbb97d36e1a0e5b66e6c0f297290f2e95f05b9b8bf18690", size = 70277, upload-time = "2026-06-11T16:10:42.383Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-py3-none-any.whl", hash = "sha256:475803f53b7b2ed6e490e27373f9d8340f7d2eebf9acdaf645d7d714c97bb500", size = 33886, upload-time = "2026-06-11T16:10:41.192Z" }, +] + [[package]] name = "requests" version = "2.33.1" @@ -734,6 +1080,15 @@ 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 = "roman-numerals" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, +] + [[package]] name = "ruff" version = "0.15.12" @@ -759,6 +1114,160 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, ] +[[package]] +name = "snowballstemmer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/f8/0a71edf031f03c40db17503cb8ca78a69a171254e568e7db241b0ab57ea1/snowballstemmer-3.1.1.tar.gz", hash = "sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260", size = 123314, upload-time = "2026-06-03T00:56:40.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl", hash = "sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752", size = 104164, upload-time = "2026-06-03T00:56:38.614Z" }, +] + +[[package]] +name = "sphinx" +version = "9.0.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version < '3.12'" }, + { name = "babel", marker = "python_full_version < '3.12'" }, + { name = "colorama", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, + { name = "docutils", marker = "python_full_version < '3.12'" }, + { name = "imagesize", marker = "python_full_version < '3.12'" }, + { name = "jinja2", marker = "python_full_version < '3.12'" }, + { name = "packaging", marker = "python_full_version < '3.12'" }, + { name = "pygments", marker = "python_full_version < '3.12'" }, + { name = "requests", marker = "python_full_version < '3.12'" }, + { name = "roman-numerals", marker = "python_full_version < '3.12'" }, + { name = "snowballstemmer", marker = "python_full_version < '3.12'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.12'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.12'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.12'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.12'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.12'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/3f/4bbd76424c393caead2e1eb89777f575dee5c8653e2d4b6afd7a564f5974/sphinx-9.0.4-py3-none-any.whl", hash = "sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb", size = 3917713, upload-time = "2025-12-04T07:45:24.944Z" }, +] + +[[package]] +name = "sphinx" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.12' and python_full_version < '3.15'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version >= '3.12'" }, + { name = "babel", marker = "python_full_version >= '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "docutils", marker = "python_full_version >= '3.12'" }, + { name = "imagesize", marker = "python_full_version >= '3.12'" }, + { name = "jinja2", marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "requests", marker = "python_full_version >= '3.12'" }, + { name = "roman-numerals", marker = "python_full_version >= '3.12'" }, + { name = "snowballstemmer", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, +] + +[[package]] +name = "sphinx-rtd-theme" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-jquery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/68/a1bfbf38c0f7bccc9b10bbf76b94606f64acb1552ae394f0b8285bfaea25/sphinx_rtd_theme-3.1.0.tar.gz", hash = "sha256:b44276f2c276e909239a4f6c955aa667aaafeb78597923b1c60babc76db78e4c", size = 7620915, upload-time = "2026-01-12T16:03:31.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/c7/b5c8015d823bfda1a346adb2c634a2101d50bb75d421eb6dcb31acd25ebc/sphinx_rtd_theme-3.1.0-py2.py3-none-any.whl", hash = "sha256:1785824ae8e6632060490f67cf3a72d404a85d2d9fc26bce3619944de5682b89", size = 7655617, upload-time = "2026-01-12T16:03:28.101Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jquery" +version = "4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/f3/aa67467e051df70a6330fe7770894b3e4f09436dea6881ae0b4f3d87cad8/sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a", size = 122331, upload-time = "2023-03-14T15:01:01.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/85/749bd22d1a68db7291c89e2ebca53f4306c3f205853cf31e9de279034c3c/sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae", size = 121104, upload-time = "2023-03-14T15:01:00.356Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + [[package]] name = "starlette" version = "1.0.0" @@ -802,6 +1311,21 @@ 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" }, ] +[[package]] +name = "virtualenv" +version = "21.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/a5/81f987504738e6defeed61ec1c47e2aefab3c35d8eeb87e1b3f38cf28254/virtualenv-21.5.1.tar.gz", hash = "sha256:dca3bf98275a59c652b69d68e73433e597d977c2da9198882479d1a7188009c8", size = 4578798, upload-time = "2026-06-16T16:23:58.603Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-py3-none-any.whl", hash = "sha256:55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783", size = 4558820, upload-time = "2026-06-16T16:23:56.963Z" }, +] + [[package]] name = "werkzeug" version = "3.1.8" From cc3e6147db6adab14de4c1b2c78c2cda983188b0 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Thu, 18 Jun 2026 10:41:30 -0700 Subject: [PATCH 60/63] add type file --- src/dataone/py.typed | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/dataone/py.typed diff --git a/src/dataone/py.typed b/src/dataone/py.typed new file mode 100644 index 0000000..e69de29 From 138fb29cee2762fcc68f758132df3021dac56725 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Thu, 18 Jun 2026 11:11:17 -0700 Subject: [PATCH 61/63] maybe fix the typing... --- src/dataone/auth.py | 12 +-- uv.lock | 211 +------------------------------------------- 2 files changed, 7 insertions(+), 216 deletions(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 21524a0..de2f51d 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -554,11 +554,11 @@ def login(self, redirect_uri: str, request=None) -> Any: """This is implemented by subclasses.""" raise NotImplementedError - def authorize(self, request=None): + def authorize(self, request=None) -> Any: """This is implemented by subclasses.""" raise NotImplementedError - def refresh(self, request_json: dict): + def refresh(self, request_json: dict) -> Any: """This is implemented by subclasses.""" raise NotImplementedError @@ -724,7 +724,7 @@ async def login(request: Request): # The Starlette client's authorize_redirect is async return await self.dataone_oidc.authorize_redirect(request, redirect_uri) - async def authorize(self, request): + async def authorize(self, request) -> Any: # type: ignore[override] """Asynchronously exchanges an authorization code for an access token. This method is designed to be used in the OIDC callback route. It @@ -750,7 +750,7 @@ async def authorize(request: Request): except Exception as e: return self._error_handler(e) - async def refresh(self, request_json: dict): + async def refresh(self, request_json: dict) -> Any: """Asynchronously exchanges a refresh token for new access tokens. Overrides the synchronous base method to accommodate FastAPI's async @@ -992,7 +992,7 @@ def login(): """ return self.dataone_oidc.authorize_redirect(redirect_uri) - def authorize(self): + def authorize(self) -> Any: # type: ignore[override] """Exchanges an authorization code for an access token in Flask. This method should be called within the OIDC callback route. It @@ -1014,7 +1014,7 @@ def authorize(): except Exception as e: return self._error_handler(e) - def refresh(self, request_json: dict): + def refresh(self, request_json: dict) -> Any: """Executes the synchronous token refresh request for Flask. Args: diff --git a/uv.lock b/uv.lock index 9cc666f..c0b2981 100644 --- a/uv.lock +++ b/uv.lock @@ -7,15 +7,6 @@ resolution-markers = [ "python_full_version < '3.12'", ] -[[package]] -name = "alabaster" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, -] - [[package]] name = "annotated-doc" version = "0.0.4" @@ -118,15 +109,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, ] -[[package]] -name = "babel" -version = "2.18.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, -] - [[package]] name = "blinker" version = "1.9.0" @@ -408,10 +390,6 @@ dependencies = [ ] [package.optional-dependencies] -docs = [ - { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, -] fastapi = [ { name = "fastapi" }, { name = "httpx" }, @@ -431,9 +409,6 @@ dev = [ { name = "nox" }, { name = "pytest" }, { name = "ruff" }, - { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "sphinx-rtd-theme" }, ] [package.metadata] @@ -446,11 +421,10 @@ requires-dist = [ { name = "httpx", marker = "extra == 'starlette'", specifier = ">=0.28.1" }, { name = "joserfc", specifier = ">=1.6.5" }, { name = "requests", specifier = ">=2.33.1" }, - { name = "sphinx", marker = "extra == 'docs'", specifier = ">=7.0.0" }, { name = "starlette", marker = "extra == 'fastapi'", specifier = ">=1.0.0" }, { name = "werkzeug", marker = "extra == 'flask'", specifier = ">=3.1.8" }, ] -provides-extras = ["flask", "fastapi", "starlette", "docs"] +provides-extras = ["flask", "fastapi", "starlette"] [package.metadata.requires-dev] dev = [ @@ -458,8 +432,6 @@ dev = [ { name = "nox" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "ruff", specifier = ">=0.15.12" }, - { name = "sphinx", specifier = ">=9.0.4" }, - { name = "sphinx-rtd-theme", specifier = ">=3.1.0" }, ] [[package]] @@ -483,15 +455,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, ] -[[package]] -name = "docutils" -version = "0.22.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, -] - [[package]] name = "fastapi" version = "0.136.1" @@ -589,15 +552,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, ] -[[package]] -name = "imagesize" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, -] - [[package]] name = "iniconfig" version = "2.3.0" @@ -1080,15 +1034,6 @@ 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 = "roman-numerals" -version = "4.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, -] - [[package]] name = "ruff" version = "0.15.12" @@ -1114,160 +1059,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, ] -[[package]] -name = "snowballstemmer" -version = "3.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/f8/0a71edf031f03c40db17503cb8ca78a69a171254e568e7db241b0ab57ea1/snowballstemmer-3.1.1.tar.gz", hash = "sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260", size = 123314, upload-time = "2026-06-03T00:56:40.194Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl", hash = "sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752", size = 104164, upload-time = "2026-06-03T00:56:38.614Z" }, -] - -[[package]] -name = "sphinx" -version = "9.0.4" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.12'", -] -dependencies = [ - { name = "alabaster", marker = "python_full_version < '3.12'" }, - { name = "babel", marker = "python_full_version < '3.12'" }, - { name = "colorama", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, - { name = "docutils", marker = "python_full_version < '3.12'" }, - { name = "imagesize", marker = "python_full_version < '3.12'" }, - { name = "jinja2", marker = "python_full_version < '3.12'" }, - { name = "packaging", marker = "python_full_version < '3.12'" }, - { name = "pygments", marker = "python_full_version < '3.12'" }, - { name = "requests", marker = "python_full_version < '3.12'" }, - { name = "roman-numerals", marker = "python_full_version < '3.12'" }, - { name = "snowballstemmer", marker = "python_full_version < '3.12'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.12'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.12'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.12'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.12'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.12'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.12'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/3f/4bbd76424c393caead2e1eb89777f575dee5c8653e2d4b6afd7a564f5974/sphinx-9.0.4-py3-none-any.whl", hash = "sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb", size = 3917713, upload-time = "2025-12-04T07:45:24.944Z" }, -] - -[[package]] -name = "sphinx" -version = "9.1.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version >= '3.12' and python_full_version < '3.15'", -] -dependencies = [ - { name = "alabaster", marker = "python_full_version >= '3.12'" }, - { name = "babel", marker = "python_full_version >= '3.12'" }, - { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, - { name = "docutils", marker = "python_full_version >= '3.12'" }, - { name = "imagesize", marker = "python_full_version >= '3.12'" }, - { name = "jinja2", marker = "python_full_version >= '3.12'" }, - { name = "packaging", marker = "python_full_version >= '3.12'" }, - { name = "pygments", marker = "python_full_version >= '3.12'" }, - { name = "requests", marker = "python_full_version >= '3.12'" }, - { name = "roman-numerals", marker = "python_full_version >= '3.12'" }, - { name = "snowballstemmer", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, -] - -[[package]] -name = "sphinx-rtd-theme" -version = "3.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "docutils" }, - { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-jquery" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/84/68/a1bfbf38c0f7bccc9b10bbf76b94606f64acb1552ae394f0b8285bfaea25/sphinx_rtd_theme-3.1.0.tar.gz", hash = "sha256:b44276f2c276e909239a4f6c955aa667aaafeb78597923b1c60babc76db78e4c", size = 7620915, upload-time = "2026-01-12T16:03:31.17Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/87/c7/b5c8015d823bfda1a346adb2c634a2101d50bb75d421eb6dcb31acd25ebc/sphinx_rtd_theme-3.1.0-py2.py3-none-any.whl", hash = "sha256:1785824ae8e6632060490f67cf3a72d404a85d2d9fc26bce3619944de5682b89", size = 7655617, upload-time = "2026-01-12T16:03:28.101Z" }, -] - -[[package]] -name = "sphinxcontrib-applehelp" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, -] - -[[package]] -name = "sphinxcontrib-devhelp" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, -] - -[[package]] -name = "sphinxcontrib-htmlhelp" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, -] - -[[package]] -name = "sphinxcontrib-jquery" -version = "4.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/de/f3/aa67467e051df70a6330fe7770894b3e4f09436dea6881ae0b4f3d87cad8/sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a", size = 122331, upload-time = "2023-03-14T15:01:01.944Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/85/749bd22d1a68db7291c89e2ebca53f4306c3f205853cf31e9de279034c3c/sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae", size = 121104, upload-time = "2023-03-14T15:01:00.356Z" }, -] - -[[package]] -name = "sphinxcontrib-jsmath" -version = "1.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, -] - -[[package]] -name = "sphinxcontrib-qthelp" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, -] - -[[package]] -name = "sphinxcontrib-serializinghtml" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, -] - [[package]] name = "starlette" version = "1.0.0" From 2ed32737fb8c3a1639dea6cb611bf04e2a57f39c Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Wed, 8 Jul 2026 14:34:16 -0700 Subject: [PATCH 62/63] bump version down, since we haven't tagged anything yet --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 99d0a30..3d343d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "dataone-auth" -version = "0.2.0" +version = "0.1.0" description = "DataONE OIDC Auth package" readme = "README.md" authors = [ From e2f4e6e0663bfdd0d896afce33a5d741944c3877 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Wed, 8 Jul 2026 14:36:38 -0700 Subject: [PATCH 63/63] run nox and ruff --- noxfile.py | 2 -- uv.lock | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/noxfile.py b/noxfile.py index 566bbc0..47d9e68 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,8 +1,6 @@ from __future__ import annotations -import argparse import os -import shutil from pathlib import Path import nox diff --git a/uv.lock b/uv.lock index c0b2981..097a8d2 100644 --- a/uv.lock +++ b/uv.lock @@ -380,7 +380,7 @@ wheels = [ [[package]] name = "dataone-auth" -version = "0.2.0" +version = "0.1.0" source = { editable = "." } dependencies = [ { name = "authlib" },