chore: satisfy pylint ruff checks

This commit is contained in:
Manoj Bajaj
2026-06-04 22:22:50 +05:30
parent 10f6e8ae41
commit a033bfe93f
55 changed files with 354 additions and 287 deletions
+2
View File
@@ -80,6 +80,8 @@ These rules govern all changes to this codebase — apply them without exception
**No hallucinated APIs.** If unsure a method or parameter exists, search the codebase before using it.
**Use real runtime types.** Do not hide missing imports or circular dependencies behind quoted annotations, `TYPE_CHECKING` imports, or postponed annotations. If an annotation names a class from another module, import it normally and let import-time failures reveal architectural cycles. Fix those cycles at the ownership boundary. Use `Self` for same-class return types.
**Read before write.** Understand the existing implementation before modifying it.
**Prefer reversible changes.** Avoid destructive operations without explicit user confirmation.
+3
View File
@@ -72,6 +72,9 @@ If there are multiple valid approaches, say so and present the tradeoffs. Silent
**No hallucinated APIs.**
If you're unsure a method, parameter, or module exists, search for it in the codebase before using it. Plausible-sounding code that doesn't run wastes more time than asking first.
**Use real runtime types.**
Do not hide missing imports or circular dependencies behind quoted annotations, `TYPE_CHECKING` imports, or postponed annotations. If an annotation names a class from another module, import it normally and let import-time failures reveal architectural cycles. Fix those cycles at the ownership boundary. Use `Self` for same-class return types.
**Read before write.**
Understand the existing implementation before modifying it. Assumptions about structure lead to subtle bugs that are expensive to diagnose.
+8 -2
View File
@@ -84,7 +84,8 @@ line-length = 120
target-version = "py313"
[tool.ruff.lint]
select = [# pycodestyle
select = [
# pycodestyle
"E",
# Pyflakes
"F",
@@ -95,9 +96,14 @@ select = [# pycodestyle
# flake8-simplify
"SIM",
# isort
"I"]
"I",
# pylint
"PL"]
ignore = ['B008']
[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["PLC0415"]
[tool.ruff.lint.mccabe]
max-complexity = 20
+1 -1
View File
@@ -46,7 +46,7 @@ def emit(event: AuditEvent) -> AuditEvent:
return event
def emit_event(
def emit_event( # noqa: PLR0913
event: str,
*,
source: AuditSource = "internal",
+2 -2
View File
@@ -15,7 +15,7 @@ from authsome.utils import utc_now
class ApiKeyFlow(AuthFlow):
"""Stores a user-provided API key as a connection record."""
async def begin(
async def begin( # noqa: PLR0913
self,
provider: ProviderDefinition,
identity: str | None,
@@ -32,7 +32,7 @@ class ApiKeyFlow(AuthFlow):
runtime_session.state = "waiting_for_user"
runtime_session.payload["input_required"] = "api_key"
async def resume(
async def resume( # noqa: PLR0913
self,
provider: ProviderDefinition,
identity: str | None,
+2 -2
View File
@@ -36,7 +36,7 @@ class AuthFlow(ABC):
"""
@abstractmethod
async def begin(
async def begin( # noqa: PLR0913
self,
provider: ProviderDefinition,
identity: str | None,
@@ -55,7 +55,7 @@ class AuthFlow(ABC):
...
@abstractmethod
async def resume(
async def resume( # noqa: PLR0913
self,
provider: ProviderDefinition,
identity: str | None,
+2 -2
View File
@@ -33,7 +33,7 @@ _DEFAULT_TIMEOUT = 300.0
class BrowserFlow(AuthFlow):
"""Cookie-based browser SSO — reads Chrome's on-disk cookie database."""
async def begin(
async def begin( # noqa: PLR0913
self,
provider: ProviderDefinition,
identity: str | None,
@@ -55,7 +55,7 @@ class BrowserFlow(AuthFlow):
runtime_session.payload["ttl_from_cookie"] = cfg.ttl_from_cookie
runtime_session.payload["ttl_hours"] = cfg.ttl_hours
async def resume(
async def resume( # noqa: PLR0913
self,
provider: ProviderDefinition,
identity: str | None,
+5 -4
View File
@@ -7,6 +7,7 @@ from datetime import timedelta
from typing import Any
import requests as http_client
from fastapi import status
from authsome.auth.flows.base import AuthFlow, FlowResult
from authsome.auth.models.connection import AccountInfo, ConnectionRecord, ProviderClientRecord
@@ -23,7 +24,7 @@ class DcrPkceFlow(AuthFlow):
callback_port: int = 7999
async def begin(
async def begin( # noqa: PLR0913
self,
provider: ProviderDefinition,
identity: str | None,
@@ -74,7 +75,7 @@ class DcrPkceFlow(AuthFlow):
if client_secret:
runtime_session.payload["internal_client_secret"] = client_secret
async def resume(
async def resume( # noqa: PLR0913
self,
provider: ProviderDefinition,
identity: str | None,
@@ -171,7 +172,7 @@ class DcrPkceFlow(AuthFlow):
]:
try:
resp = http_client.get(url, timeout=15)
if resp.status_code == 200:
if resp.status_code == status.HTTP_200_OK:
reg_endpoint = resp.json().get("registration_endpoint")
if reg_endpoint:
return reg_endpoint
@@ -221,7 +222,7 @@ class DcrPkceFlow(AuthFlow):
return client_id, reg_data.get("client_secret")
@staticmethod
async def _exchange_code(
async def _exchange_code( # noqa: PLR0913
*,
provider: ProviderDefinition,
auth_code: str,
+7 -7
View File
@@ -1,11 +1,13 @@
"""OAuth2 Device Authorization Grant (RFC 8628)."""
import asyncio
import json
import time
from datetime import timedelta
from typing import Any
import requests
from fastapi import status
from loguru import logger
from authsome.auth.flows.base import AuthFlow, FlowResult
@@ -23,7 +25,7 @@ _MAX_POLL_DURATION = 900
class DeviceCodeFlow(AuthFlow):
"""OAuth2 Device Authorization Grant — headless flow."""
async def begin(
async def begin( # noqa: PLR0913
self,
provider: ProviderDefinition,
identity: str | None,
@@ -67,7 +69,7 @@ class DeviceCodeFlow(AuthFlow):
runtime_session.payload["expires_in"] = str(expires_in)
runtime_session.payload["internal_scopes"] = json.dumps(effective_scopes)
async def resume(
async def resume( # noqa: PLR0913
self,
provider: ProviderDefinition,
identity: str | None,
@@ -119,7 +121,7 @@ class DeviceCodeFlow(AuthFlow):
)
error = data.get("error", "")
if error == "authorization_pending" or error == "slow_down":
if error in {"authorization_pending", "slow_down"}:
return None
elif error == "access_denied":
raise AuthenticationFailedError("User denied the authorization request", provider=provider.name)
@@ -159,7 +161,7 @@ class DeviceCodeFlow(AuthFlow):
"Device authorization response was not valid JSON", provider=provider.name
) from exc
async def poll_for_token(
async def poll_for_token( # noqa: PLR0912, PLR0913
self,
provider: ProviderDefinition,
client_id: str | None,
@@ -168,8 +170,6 @@ class DeviceCodeFlow(AuthFlow):
interval: int,
expires_in: int,
) -> dict[str, Any]:
import asyncio
assert provider.oauth is not None
poll_interval = max(interval, 1)
@@ -212,7 +212,7 @@ class DeviceCodeFlow(AuthFlow):
logger.warning("Token poll response was not JSON, retrying...")
continue
if resp.status_code == 200 and "access_token" in data:
if resp.status_code == status.HTTP_200_OK and "access_token" in data:
return data
error = data.get("error", "")
+3 -3
View File
@@ -23,7 +23,7 @@ class PkceFlow(AuthFlow):
callback_port: int = 7999 # TODO: Remove hardcoded ports, better to keep a global in config file
async def begin(
async def begin( # noqa: PLR0913
self,
provider: ProviderDefinition,
identity: str | None,
@@ -65,7 +65,7 @@ class PkceFlow(AuthFlow):
runtime_session.payload["internal_state"] = state
runtime_session.payload["internal_scopes"] = json.dumps(effective_scopes)
async def resume(
async def resume( # noqa: PLR0913
self,
provider: ProviderDefinition,
identity: str | None,
@@ -133,7 +133,7 @@ class PkceFlow(AuthFlow):
)
@staticmethod
async def _exchange_code(
async def _exchange_code( # noqa: PLR0913
*,
provider: ProviderDefinition,
auth_code: str,
+3 -1
View File
@@ -4,6 +4,8 @@ from importlib.metadata import PackageNotFoundError, version
from pydantic import BaseModel, Field
_MIN_VERSION_PARTS = 2
# TODO: This is generic package level function, move it there
def current_spec_version() -> int:
@@ -13,7 +15,7 @@ def current_spec_version() -> int:
except PackageNotFoundError:
return 0
parts = package_version.split(".")
if len(parts) < 2:
if len(parts) < _MIN_VERSION_PARTS:
return 0
try:
return int(parts[1])
+1 -1
View File
@@ -55,7 +55,7 @@ class AuthSessionStore:
self._sessions: dict[str, AuthSession] = {}
self._state_index: dict[str, str] = {}
async def create(
async def create( # noqa: PLR0913
self,
*,
provider: str,
+1 -1
View File
@@ -99,7 +99,7 @@ def validate_provider_definition(definition: ProviderDefinition) -> None:
)
def required_inputs(
def required_inputs( # noqa: PLR0913
*,
provider: ProviderDefinition,
flow_type: FlowType,
+9 -11
View File
@@ -11,7 +11,9 @@ from typing import Any
from urllib.parse import urlparse
import httpx
from fastapi import status
import authsome.errors as err_mod
from authsome.cli.identity import (
RuntimeIdentity,
load_runtime_identity,
@@ -53,15 +55,11 @@ def raise_for_error(response: httpx.Response) -> None:
obj = None
try:
data = response.json()
if response.status_code == 401 and data.get("detail") == "Unknown identity handle":
import authsome.errors as err_mod
if response.status_code == status.HTTP_401_UNAUTHORIZED and data.get("detail") == "Unknown identity handle":
raise err_mod.IdentityNotRegisteredError("current identity") from exc
error_name = data.get("error")
message = data.get("message")
if error_name and message:
import authsome.errors as err_mod
exc_cls = getattr(err_mod, error_name, None)
if exc_cls and issubclass(exc_cls, err_mod.AuthsomeError):
obj = exc_cls.__new__(exc_cls)
@@ -121,7 +119,7 @@ class AuthsomeApiClient:
content=body_bytes if body is not None else None,
headers=headers,
)
if protected and _retry and response.status_code == 401:
if protected and _retry and response.status_code == status.HTTP_401_UNAUTHORIZED:
try:
detail = response.json().get("detail", "")
except Exception:
@@ -167,15 +165,15 @@ class AuthsomeApiClient:
async def _check_server_registration(self, runtime: RuntimeIdentity) -> None:
"""Verify registration with the server; register and claim if needed."""
try:
status = await self.get_identity_status(runtime.handle)
identity_status = await self.get_identity_status(runtime.handle)
except httpx.HTTPStatusError as exc:
if exc.response.status_code != 404:
if exc.response.status_code != status.HTTP_404_NOT_FOUND:
raise
status = await self.register_identity(runtime.handle, runtime.did)
identity_status = await self.register_identity(runtime.handle, runtime.did)
reg_status = status.get("registration_status", "")
reg_status = identity_status.get("registration_status", "")
if reg_status == "claim_required":
claim_url = status.get("claim_url", "")
claim_url = identity_status.get("claim_url", "")
if claim_url:
self._open_claim_url(claim_url)
await self._poll_claim_completion(runtime.handle)
+1 -1
View File
@@ -4,6 +4,7 @@ import sys
import click
from authsome.auth.models.connection import ConnectionRecord
from authsome.cli.context import ContextObj
from authsome.cli.helpers import auth_command
from authsome.utils import redact
@@ -35,7 +36,6 @@ async def inspect_connection(ctx_obj: ContextObj, provider: str, connection: str
actx = await ctx_obj.initialize()
await actx.runtime_client.get_provider(provider)
record_dict = await actx.runtime_client.get_connection(provider, connection)
from authsome.auth.models.connection import ConnectionRecord
record = ConnectionRecord.model_validate(record_dict)
data = redact(record)
+5 -7
View File
@@ -1,6 +1,7 @@
"""Root CLI commands."""
import sys
import webbrowser
from contextlib import suppress
from typing import Any
@@ -8,6 +9,7 @@ import click
from loguru import logger
from authsome import FlowType
from authsome.auth.flows.browser import BrowserFlow
from authsome.auth.models.enums import AuthType
from authsome.auth.models.provider import ProviderDefinition
from authsome.cli.context import ContextObj
@@ -17,6 +19,7 @@ from authsome.cli.helpers import (
_scan_resolve_should_import,
auth_command,
)
from authsome.cli.identity import ensure_local_identity
from authsome.config import get_authsome_config
from authsome.paths import get_client_log_path
from authsome.utils import connection_is_active
@@ -58,7 +61,7 @@ def _build_login_json_payload(session_info: dict[str, Any], provider: str, conne
@click.option("--base-url", metavar="URL", help="Override provider API base URL (e.g. for self-hosted enterprise).")
@click.option("--force", is_flag=True, help="Overwrite an existing connection if it already exists.")
@auth_command
async def login(
async def login( # noqa: PLR0913
ctx_obj: ContextObj,
provider: str,
connection: str,
@@ -88,14 +91,11 @@ async def login(
if action_type == "open_url":
auth_url = next_action["url"]
import webbrowser
with suppress(Exception):
webbrowser.open(auth_url)
elif action_type == "browser":
from authsome.auth.flows.browser import BrowserFlow
credentials = await BrowserFlow.run_login(next_action, provider)
session_info = await actx.runtime_client.resume_login_session(session_info["id"], credentials=credentials)
login_result = _build_login_json_payload(session_info, provider, connection)
@@ -115,7 +115,7 @@ async def login(
@click.option("--connection", default="default", metavar="NAME", help="Connection name.")
@click.option("--import", "auto_import", is_flag=True, help="Import detected keys without interactive prompt.")
@auth_command
async def scan(ctx_obj: ContextObj, connection: str, auto_import: bool) -> None:
async def scan(ctx_obj: ContextObj, connection: str, auto_import: bool) -> None: # noqa: PLR0912, PLR0915
"""Scan env files and process env for provider API keys.
Returns a drift report by default unless ``--import`` is also passed.
@@ -270,8 +270,6 @@ async def run(ctx_obj: ContextObj, command: tuple[str]) -> None:
@auth_command
async def init(ctx_obj: ContextObj) -> None:
"""Initialize local storage and register a fresh profile."""
from authsome.cli.identity import ensure_local_identity
home = get_authsome_config().home
identity = ensure_local_identity(home)
+7 -8
View File
@@ -4,6 +4,7 @@ import sys
import click
from authsome.cli import daemon_control
from authsome.cli.client import resolve_daemon_url
from authsome.cli.context import ContextObj
from authsome.cli.daemon_control import (
@@ -18,6 +19,7 @@ from authsome.cli.daemon_control import (
)
from authsome.cli.helpers import auth_command
from authsome.server.config import get_server_config
from authsome.server.daemon import serve
@click.group(name="daemon")
@@ -40,8 +42,6 @@ def daemon() -> None:
@click.option("--reload", is_flag=True, help="Enable auto-reload on code changes.")
def daemon_serve(host: str, port: int, reload: bool) -> None:
"""Run the daemon in the foreground."""
from authsome.server.daemon import serve
serve(host=host, port=port, reload=reload)
@@ -152,10 +152,9 @@ async def daemon_status_cmd(ctx_obj: ContextObj) -> None:
@auth_command
async def daemon_logs(ctx_obj: ContextObj, lines: int) -> None:
"""Show daemon log output."""
from authsome.cli.daemon_control import LOG_FILE
if not LOG_FILE.exists():
ctx_obj.print_json({"log_file": str(LOG_FILE), "entries": []})
log_file = daemon_control.LOG_FILE
if not log_file.exists():
ctx_obj.print_json({"log_file": str(log_file), "entries": []})
return
entries = LOG_FILE.read_text(encoding="utf-8", errors="replace").splitlines()[-lines:]
ctx_obj.print_json({"log_file": str(LOG_FILE), "entries": entries})
entries = log_file.read_text(encoding="utf-8", errors="replace").splitlines()[-lines:]
ctx_obj.print_json({"log_file": str(log_file), "entries": entries})
+2 -5
View File
@@ -2,8 +2,10 @@
import click
from authsome.cli.config import load_client_config, save_client_config
from authsome.cli.context import ContextObj
from authsome.cli.helpers import auth_command
from authsome.cli.identity import create_identity, load_identity
from authsome.config import get_authsome_config
@@ -17,8 +19,6 @@ def profile() -> None:
@auth_command
async def profile_create(ctx_obj: ContextObj, handle: str | None) -> None:
"""Create a local profile keypair."""
from authsome.cli.identity import create_identity
home = get_authsome_config().home
identity_meta = create_identity(home, handle)
@@ -38,9 +38,6 @@ async def profile_create(ctx_obj: ContextObj, handle: str | None) -> None:
@auth_command
async def profile_use(ctx_obj: ContextObj, handle: str) -> None:
"""Select the active local profile."""
from authsome.cli.config import load_client_config, save_client_config
from authsome.cli.identity import load_identity
home = get_authsome_config().home
identity_meta = load_identity(home, handle)
save_client_config(home, load_client_config(home).model_copy(update={"active_identity": identity_meta.handle}))
+2 -3
View File
@@ -4,6 +4,7 @@ import asyncio
import json
import os
import signal
import socket
import subprocess
import sys
import time
@@ -12,6 +13,7 @@ from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from authsome import __version__
from authsome.cli.client import (
AuthsomeApiClient,
is_managed_local_daemon_url,
@@ -45,8 +47,6 @@ async def is_daemon_responsive() -> bool:
def is_port_occupied() -> bool:
"""Return whether the configured daemon port is currently occupied by any listening process."""
import socket
host, port = _resolved_host_port()
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(0.2)
@@ -170,7 +170,6 @@ async def daemon_status() -> dict[str, Any]:
async def _is_ready(client: AuthsomeApiClient) -> bool:
try:
health = await client.health()
from authsome import __version__
if health.get("version") != __version__:
return False
+6 -5
View File
@@ -1,6 +1,8 @@
"""CLI specific utilities for authsome."""
import asyncio
import functools
import inspect
import ipaddress
import os
import sys
@@ -15,15 +17,14 @@ from authsome.auth.models.provider import ProviderDefinition
from authsome.cli.context import ContextObj, common_options, pass_ctx
from authsome.utils import format_error_code
_MIN_QUOTED_VALUE_LENGTH = 2
def handle_errors(func):
"""Catch exceptions and return structured JSON errors."""
@functools.wraps(func)
def wrapper(ctx_obj: ContextObj, *args, **kwargs):
import asyncio
import inspect
try:
if inspect.iscoroutinefunction(func):
return asyncio.run(func(ctx_obj, *args, **kwargs))
@@ -62,7 +63,7 @@ def setup_logging(verbose: bool, log_file: Path | None) -> None:
)
def _validate_provider_endpoints(definition: Any) -> list[tuple[str, str, bool]]:
def _validate_provider_endpoints(definition: Any) -> list[tuple[str, str, bool]]: # noqa: PLR0912
"""Extract and validate provider endpoints for security."""
endpoints_to_check: list[tuple[str, str, bool]] = []
if definition.oauth:
@@ -137,7 +138,7 @@ def _load_dotenv(path: Path) -> dict[str, str]:
value = value.strip()
if not key:
continue
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
if len(value) >= _MIN_QUOTED_VALUE_LENGTH and value[0] == value[-1] and value[0] in {"'", '"'}:
value = value[1:-1]
values[key] = value
+2 -3
View File
@@ -7,8 +7,10 @@ from contextlib import suppress
from pathlib import Path
from typing import Any, Protocol
import certifi
from loguru import logger
from authsome.auth.models.provider import ProviderDefinition
from authsome.cli.config import load_client_config, save_client_config
from authsome.config import get_authsome_config
from authsome.proxy.certs import ensure_local_proxy_ca
@@ -95,7 +97,6 @@ class ProxyRunner:
},
}
connected_names = {entry["name"] for entry in connections_data["connections"]}
from authsome.auth.models.provider import ProviderDefinition
for source_providers in connections_data["by_source"].values():
for provider_dict in source_providers:
@@ -121,8 +122,6 @@ class ProxyRunner:
logger.warning("Mitmproxy CA cert not found at {}; HTTPS may fail", mitm_ca)
return None
import certifi
system_ca_path = Path(certifi.where())
fd, name = tempfile.mkstemp(prefix="authsome-ca-", suffix=".pem", text=True)
+3 -2
View File
@@ -13,6 +13,7 @@ from pydantic import BaseModel, Field
_ED25519_MULTICODEC_PREFIX = b"\xed\x01"
_DID_KEY_PREFIX = "did:key:z"
_HANDLE_RE = re.compile(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$")
_ED25519_KEY_BYTES = 32
# TODO: The list is very small, will start creating conflicts soon. Use a library like randomword.
_ADJECTIVES = (
@@ -100,7 +101,7 @@ def public_key_from_did_key(did: str) -> Ed25519PublicKey:
if not decoded.startswith(_ED25519_MULTICODEC_PREFIX):
raise ValueError("did:key does not use the Ed25519 multicodec prefix")
raw_key = decoded[len(_ED25519_MULTICODEC_PREFIX) :]
if len(raw_key) != 32:
if len(raw_key) != _ED25519_KEY_BYTES:
raise ValueError("Ed25519 did:key public key must be 32 bytes")
return Ed25519PublicKey.from_public_bytes(raw_key)
@@ -119,7 +120,7 @@ def private_key_from_hex(value: str) -> Ed25519PrivateKey:
raw = bytes.fromhex(value.strip())
except ValueError as exc:
raise ValueError("Malformed Ed25519 private key hex") from exc
if len(raw) != 32:
if len(raw) != _ED25519_KEY_BYTES:
raise ValueError("Ed25519 private key must be 32 bytes")
return Ed25519PrivateKey.from_private_bytes(raw)
+2 -2
View File
@@ -46,7 +46,7 @@ def body_sha256(body: bytes) -> str:
return hashlib.sha256(body).hexdigest()
def create_proof_jwt(
def create_proof_jwt( # noqa: PLR0913
*,
private_key: Ed25519PrivateKey,
issuer: str,
@@ -72,7 +72,7 @@ def create_proof_jwt(
return jwt.encode(payload, private_key, algorithm="EdDSA")
def validate_proof_jwt(
def validate_proof_jwt( # noqa: PLR0913
*,
token: str,
method: str,
+4 -3
View File
@@ -5,9 +5,10 @@ import sys
from pathlib import Path
from loguru import logger
from mitmproxy.certs import CertStore
def ensure_local_proxy_ca() -> bool:
def ensure_local_proxy_ca() -> bool: # noqa: PLR0911
"""Ensure the mitmproxy CA is generated and trusted in the macOS login keychain.
Go's crypto/x509 on macOS uses the native Security framework and ignores
@@ -24,6 +25,7 @@ def ensure_local_proxy_ca() -> bool:
check = subprocess.run(
["security", "find-certificate", "-c", "mitmproxy", str(keychain)],
capture_output=True,
check=False,
text=True,
)
if check.returncode == 0:
@@ -34,8 +36,6 @@ def ensure_local_proxy_ca() -> bool:
ca_cert_path = confdir / "mitmproxy-ca-cert.pem"
if not ca_cert_path.exists():
try:
from mitmproxy.certs import CertStore
CertStore.from_store(confdir, "mitmproxy", 2048)
logger.debug("Generated mitmproxy CA certificate at {}", ca_cert_path)
except Exception as exc:
@@ -57,6 +57,7 @@ def ensure_local_proxy_ca() -> bool:
str(ca_cert_path),
],
capture_output=True,
check=False,
text=True,
timeout=60,
)
+3 -5
View File
@@ -6,7 +6,7 @@ import threading
from dataclasses import dataclass
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Protocol, Self
from typing import Any, Protocol, Self, get_args
from urllib.parse import urlparse
from loguru import logger
@@ -15,6 +15,7 @@ from mitmproxy import http
from mitmproxy.options import Options
from mitmproxy.tools.dump import DumpMaster
from authsome.auth.models.provider import ProviderDefinition
from authsome.config import get_authsome_config
from authsome.proxy.config import ProxyMode
from authsome.proxy.router import RouteMatch, RouteResolution
@@ -119,13 +120,12 @@ class ProxyRouter:
return self.resolve(scheme, host, port, path).match
@staticmethod
async def _build_routes(
async def _build_routes( # noqa: PLR0912
client: ProxyClient,
scope: str = "connected",
) -> tuple[dict[str, tuple[_RouteTarget, ...]], tuple[_RegexRouteTarget, ...]]:
routes_by_host: dict[str, list[_RouteTarget]] = {}
regex_routes: list[_RegexRouteTarget] = []
from authsome.auth.models.provider import ProviderDefinition
if hasattr(client, "proxy_routes"):
try:
@@ -609,8 +609,6 @@ def start_proxy_server(
dashboard_url: str | None = None,
) -> RunningProxy:
"""Start a mitmproxy DumpMaster in a background thread."""
from typing import get_args
if mode not in get_args(ProxyMode):
raise ValueError(f"Invalid proxy mode {mode!r}, expected one of {get_args(ProxyMode)}")
+3 -4
View File
@@ -3,7 +3,7 @@
from typing import Any
from loguru import logger
from posthog import Posthog
from posthog import Posthog, identify_context, new_context
from authsome.server.config import get_server_config
@@ -15,7 +15,6 @@ def capture_event(identity: str, event: str, properties: dict[str, Any]) -> None
ph = _client
if ph is None:
return
from posthog import identify_context, new_context
with new_context():
identify_context(identity)
@@ -28,7 +27,7 @@ def init_posthog() -> Posthog | None:
Returns the client when analytics is enabled, otherwise None so the daemon
can run without emitting telemetry.
"""
global _client
global _client # noqa: PLW0603
settings = get_server_config()
if not settings.analytics_enabled:
@@ -47,7 +46,7 @@ def init_posthog() -> Posthog | None:
def shutdown_posthog() -> None:
"""Flush pending events and shut down the PostHog client."""
global _client
global _client # noqa: PLW0603
if _client is not None:
_client.shutdown()
_client = None
+8 -6
View File
@@ -3,7 +3,7 @@
from contextlib import asynccontextmanager
from importlib.resources import files
from fastapi import FastAPI, Request
from fastapi import FastAPI, Request, status
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
@@ -70,12 +70,12 @@ def create_app() -> FastAPI:
@app.exception_handler(AuthsomeError)
def authsome_error_handler(request: Request, exc: AuthsomeError) -> JSONResponse:
status_code = 400
status_code = status.HTTP_400_BAD_REQUEST
exc_name = exc.__class__.__name__
if exc_name in ("ConnectionNotFoundError", "ProviderNotFoundError", "IdentityNotFoundError"):
status_code = 404
status_code = status.HTTP_404_NOT_FOUND
elif exc_name == "CredentialMissingError":
status_code = 401
status_code = status.HTTP_401_UNAUTHORIZED
return JSONResponse(
status_code=status_code,
@@ -89,7 +89,9 @@ def create_app() -> FastAPI:
@app.exception_handler(IdentityRegistrationError)
def identity_registration_error_handler(request: Request, exc: IdentityRegistrationError) -> JSONResponse:
return JSONResponse(status_code=409, content={"error": "IdentityRegistrationError", "message": str(exc)})
return JSONResponse(
status_code=status.HTTP_409_CONFLICT, content={"error": "IdentityRegistrationError", "message": str(exc)}
)
@app.exception_handler(UiAuthRequiredError)
def ui_auth_required_handler(request: Request, exc: UiAuthRequiredError):
@@ -97,7 +99,7 @@ def create_app() -> FastAPI:
@app.get("/claim/{token}", include_in_schema=False)
def claim_page_redirect(token: str) -> RedirectResponse:
return RedirectResponse(url=f"/claim?token={token}", status_code=307)
return RedirectResponse(url=f"/claim?token={token}", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
app.include_router(auth_browser_router)
app.include_router(health_router, prefix="/api")
+6 -3
View File
@@ -13,6 +13,9 @@ from authsome.auth.models.connection import (
)
from authsome.vault import Vault
_VAULT_KEY_PARTS = 3
_MIN_CONNECTION_SCHEMA_VERSION = 2
class StoreKeyParts(NamedTuple):
"""Parsed components of a credential store key."""
@@ -52,7 +55,7 @@ def build_store_key(
)
def parse_store_key(key: str) -> StoreKeyParts:
def parse_store_key(key: str) -> StoreKeyParts: # noqa: PLR0911
"""Parse a credential store key into its components."""
if key.startswith("provider:") and key.endswith(":definition"):
provider = key[len("provider:") : -len(":definition")]
@@ -64,7 +67,7 @@ def parse_store_key(key: str) -> StoreKeyParts:
if key.startswith("vault:"):
parts = key.split(":", 2)
if len(parts) < 3:
if len(parts) < _VAULT_KEY_PARTS:
return StoreKeyParts()
vault = parts[1]
remainder = parts[2]
@@ -206,6 +209,6 @@ class CredentialRepository:
except json.JSONDecodeError:
logger.warning("Corrupt record at key {}", key)
return None
if data.get("schema_version", 1) < 2:
if data.get("schema_version", 1) < _MIN_CONNECTION_SCHEMA_VERSION:
return None
return ConnectionRecord.model_validate(data)
+10 -9
View File
@@ -26,7 +26,7 @@ from authsome.auth.models.connection import (
)
from authsome.auth.models.enums import AuthType, ConnectionStatus, ExportFormat, FlowType
from authsome.auth.models.provider import ProviderDefinition
from authsome.auth.sessions import AuthSession
from authsome.auth.sessions import AuthSession, AuthSessionStatus
from authsome.auth.utils import (
export_name_part,
normalize_base_url,
@@ -66,7 +66,7 @@ class CredentialService:
Coordinates provider lookup, auth flows, credential persistence, and policy checks.
"""
def __init__(
def __init__( # noqa: PLR0913
self,
*,
credentials: CredentialRepository,
@@ -241,7 +241,7 @@ class CredentialService:
"""
return await self._credentials.get_provider_client(provider)
async def update_provider_configuration(
async def update_provider_configuration( # noqa: PLR0912
self,
provider: str,
inputs: dict[str, str],
@@ -539,8 +539,6 @@ class CredentialService:
async def background_resume(self, session: AuthSession) -> None:
"""Resume a flow in a background thread."""
from authsome.auth.sessions import AuthSessionStatus
try:
await self.resume_login_flow(session, {})
session.state = AuthSessionStatus.COMPLETED
@@ -698,9 +696,12 @@ class CredentialService:
connection_name = connection_record["connection_name"]
exported = await self._export_connection_values(provider_name, connection_name)
for env_name, env_value in exported.items():
if env_name in values:
env_name = self._disambiguate_export_name(env_name, provider_name, connection_name, values)
values[env_name] = env_value
resolved_env_name = (
self._disambiguate_export_name(env_name, provider_name, connection_name, values)
if env_name in values
else env_name
)
values[resolved_env_name] = env_value
return values
return await self._export_connection_values(provider, connection)
@@ -800,7 +801,7 @@ class CredentialService:
raise CredentialMissingError("No API key stored in connection record", provider=record.provider)
return record.api_key
async def _get_oauth_token(self, record: ConnectionRecord, provider: str, connection: str) -> str:
async def _get_oauth_token(self, record: ConnectionRecord, provider: str, connection: str) -> str: # noqa: PLR0912
if record.access_token is None:
raise CredentialMissingError("No access token stored", provider=provider)
+11 -9
View File
@@ -3,7 +3,7 @@
from contextlib import suppress
from datetime import timedelta
from fastapi import Depends, HTTPException, Request
from fastapi import Depends, HTTPException, Request, status
from authsome.auth.sessions import AuthSession, AuthSessionStore
from authsome.identity.principal import PrincipalRole
@@ -118,10 +118,10 @@ async def verify_pop_caller(request: Request) -> ResolvedOwnership:
"""
authorization = request.headers.get("Authorization")
if not authorization:
raise HTTPException(status_code=401, detail="Missing PoP authorization header")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing PoP authorization header")
scheme, _, token = authorization.partition(" ")
if scheme != POP_AUTH_SCHEME or not token:
raise HTTPException(status_code=401, detail="Expected Authorization: PoP <jwt>")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Expected Authorization: PoP <jwt>")
body = await request.body()
# htu is path-only (not full URI) by design — the daemon is local-only,
@@ -138,18 +138,20 @@ async def verify_pop_caller(request: Request) -> ResolvedOwnership:
replay_cache=request.app.state.proof_replay_cache,
)
except (ProofValidationError, ValueError) as exc:
raise HTTPException(status_code=401, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
registration = await request.app.state.store.identity_registry.resolve(claims.subject)
if registration is None:
raise HTTPException(status_code=401, detail="Unknown identity handle")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unknown identity handle")
if registration.did != claims.issuer:
raise HTTPException(status_code=401, detail="Identity issuer does not match registered DID")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Identity issuer does not match registered DID"
)
try:
resolved = await _resolve_identity_ownership(request, claims.subject)
except ValueError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
request.state.identity = claims.subject
request.state.did = claims.issuer
@@ -172,7 +174,7 @@ async def get_admin_auth_service(
auth: CredentialService = Depends(get_protected_auth_service),
) -> CredentialService:
if auth.principal_role != PrincipalRole.ADMIN:
raise HTTPException(status_code=403, detail="Admin role required")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
return auth
@@ -188,7 +190,7 @@ async def get_daemon_or_browser_auth_service(request: Request) -> CredentialServ
principal_id=getattr(request.state, "ui_principal_id", None),
)
if auth is None:
raise HTTPException(status_code=401, detail="Missing or invalid browser session")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing or invalid browser session")
return auth
+39 -27
View File
@@ -2,7 +2,7 @@
from typing import Any
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse, Response
from authsome.auth.input_provider import InputField
@@ -48,7 +48,7 @@ async def _load_session_or_404(sessions: AuthSessionStore, session_id: str) -> A
try:
return await sessions.get(session_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail="Authentication session not found") from exc
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Authentication session not found") from exc
def _event_actor(session: AuthSession) -> str:
@@ -145,7 +145,7 @@ async def get_session(
) -> AuthSessionResponse:
session = await _load_session_or_404(sessions, session_id)
if session.identity != auth.identity:
raise HTTPException(status_code=404, detail="Authentication session not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Authentication session not found")
return _session_response(session, server_base_url)
@@ -159,7 +159,7 @@ async def resume_session(
) -> AuthSessionResponse:
session = await _load_session_or_404(sessions, session_id)
if session.identity != auth.identity:
raise HTTPException(status_code=404, detail="Authentication session not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Authentication session not found")
try:
record = await auth.resume_login_flow(session, body.data)
if record is None:
@@ -182,13 +182,13 @@ async def oauth_callback(
) -> Response:
state = request.query_params.get("state")
if not state:
return RedirectResponse("/auth/success?error=missing_state", status_code=303)
return RedirectResponse("/auth/success?error=missing_state", status_code=status.HTTP_303_SEE_OTHER)
try:
session = await sessions.get_by_oauth_state(state)
except KeyError:
return RedirectResponse("/auth/success?error=session_expired", status_code=303)
return RedirectResponse("/auth/success?error=session_expired", status_code=status.HTTP_303_SEE_OTHER)
if not await _ensure_browser_session_identity(request, session):
return RedirectResponse("/login", status_code=303)
return RedirectResponse("/login", status_code=status.HTTP_303_SEE_OTHER)
callback_data = dict(request.query_params)
auth = await require_auth_service(
request,
@@ -203,10 +203,14 @@ async def oauth_callback(
except Exception as exc:
_mark_failed(session, str(exc))
await sessions.save(session)
return RedirectResponse(build_auth_success_url(server_base_url, session.session_id), status_code=303)
return RedirectResponse(
build_auth_success_url(server_base_url, session.session_id), status_code=status.HTTP_303_SEE_OTHER
)
if return_url := session.payload.get("return_url"):
return RedirectResponse(str(return_url), status_code=303)
return RedirectResponse(build_auth_success_url(server_base_url, session.session_id), status_code=303)
return RedirectResponse(str(return_url), status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(
build_auth_success_url(server_base_url, session.session_id), status_code=status.HTTP_303_SEE_OTHER
)
@router.get("/sessions/{session_id}/input")
@@ -219,9 +223,9 @@ async def get_session_input(
try:
session = await sessions.get(session_id)
except KeyError:
raise HTTPException(status_code=404, detail="Authentication session not found") from None
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Authentication session not found") from None
if not await _ensure_browser_session_identity(request, session):
return RedirectResponse("/login", status_code=303)
return RedirectResponse("/login", status_code=status.HTTP_303_SEE_OTHER)
auth = await require_auth_service(
request,
identity=session.identity,
@@ -258,14 +262,14 @@ async def get_session_device_code(
try:
session = await sessions.get(session_id)
except KeyError:
raise HTTPException(status_code=404, detail="Authentication session not found") from None
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Authentication session not found") from None
if not await _ensure_browser_session_identity(request, session):
return RedirectResponse("/login", status_code=303)
return RedirectResponse("/login", status_code=status.HTTP_303_SEE_OTHER)
user_code = session.payload.get("user_code")
verification_uri = session.payload.get("verification_uri")
verification_uri_complete = session.payload.get("verification_uri_complete")
if not user_code or not verification_uri:
raise HTTPException(status_code=400, detail="This session does not have a device code")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="This session does not have a device code")
auth = await require_auth_service(
request,
identity=session.identity,
@@ -291,9 +295,9 @@ async def get_browser_session_status(
try:
session = await sessions.get(session_id)
except KeyError:
raise HTTPException(status_code=404, detail="Authentication session not found") from None
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Authentication session not found") from None
if not await _ensure_browser_session_identity(request, session):
return RedirectResponse("/login", status_code=303)
return RedirectResponse("/login", status_code=status.HTTP_303_SEE_OTHER)
return {
"id": session.session_id,
"provider": session.provider,
@@ -338,7 +342,7 @@ async def submit_browser_input(
)
async def _submit_session_input(
async def _submit_session_input( # noqa: PLR0911
*,
session_id: str,
request: Request,
@@ -349,9 +353,9 @@ async def _submit_session_input(
try:
session = await sessions.get(session_id)
except KeyError:
raise HTTPException(status_code=404, detail="Authentication session not found") from None
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Authentication session not found") from None
if not await _ensure_browser_session_identity(request, session):
return RedirectResponse("/login", status_code=303)
return RedirectResponse("/login", status_code=status.HTTP_303_SEE_OTHER)
auth = await require_auth_service(
request,
identity=session.identity,
@@ -369,8 +373,10 @@ async def _submit_session_input(
session.status_message = "Provider configuration updated"
await sessions.save(session)
if return_url := session.payload.get("return_url"):
return RedirectResponse(str(return_url), status_code=303)
return RedirectResponse(build_auth_success_url(server_base_url, session.session_id), status_code=303)
return RedirectResponse(str(return_url), status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(
build_auth_success_url(server_base_url, session.session_id), status_code=status.HTTP_303_SEE_OTHER
)
await auth.save_inputs(session, inputs)
@@ -380,8 +386,10 @@ async def _submit_session_input(
_mark_completed(session)
await sessions.save(session)
if return_url := session.payload.get("return_url"):
return RedirectResponse(str(return_url), status_code=303)
return RedirectResponse(build_auth_success_url(server_base_url, session.session_id), status_code=303)
return RedirectResponse(str(return_url), status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(
build_auth_success_url(server_base_url, session.session_id), status_code=status.HTTP_303_SEE_OTHER
)
session.payload["callback_url_override"] = build_callback_url(server_base_url)
await auth.begin_login_flow(
@@ -395,16 +403,20 @@ async def _submit_session_input(
background_tasks.add_task(auth.background_resume, session)
if session.payload.get("user_code") and session.payload.get("verification_uri"):
await sessions.save(session)
return RedirectResponse(url=build_device_url(server_base_url, session.session_id), status_code=303)
return RedirectResponse(
url=build_device_url(server_base_url, session.session_id), status_code=status.HTTP_303_SEE_OTHER
)
await sessions.index_oauth_state(session)
auth_url = session.payload.get("auth_url")
if auth_url:
await sessions.save(session)
return RedirectResponse(str(auth_url), status_code=303)
return RedirectResponse(str(auth_url), status_code=status.HTTP_303_SEE_OTHER)
await sessions.save(session)
return RedirectResponse(build_auth_success_url(server_base_url, session.session_id), status_code=303)
return RedirectResponse(
build_auth_success_url(server_base_url, session.session_id), status_code=status.HTTP_303_SEE_OTHER
)
def _session_response(session: AuthSession, server_base_url: str) -> AuthSessionResponse:
+13 -11
View File
@@ -1,6 +1,6 @@
"""Identity registration routes."""
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel
from authsome.server.analytics import capture_event
@@ -37,27 +37,29 @@ async def list_identities(
@router.post("/register")
async def register_identity(body: RegisterIdentityRequest, request: Request) -> dict[str, str]:
try:
status = await request.app.state.identity_bootstrap.register_identity(handle=body.handle, did=body.did)
registration_status = await request.app.state.identity_bootstrap.register_identity(
handle=body.handle, did=body.did
)
except IdentityRegistrationError:
raise
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
capture_event(
status.identity,
registration_status.identity,
"identity registered",
{
"registration_status": status.registration_status,
"principal_id": status.principal_id or None,
"registration_status": registration_status.registration_status,
"principal_id": registration_status.principal_id or None,
},
)
return status.to_payload()
return registration_status.to_payload()
@router.get("/{handle}")
async def get_identity_status(handle: str, request: Request) -> dict[str, str]:
status = await request.app.state.identity_bootstrap.get_identity_status(handle=handle)
if status is None:
raise HTTPException(status_code=404, detail="Identity not found")
payload = status.to_payload()
registration_status = await request.app.state.identity_bootstrap.get_identity_status(handle=handle)
if registration_status is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Identity not found")
payload = registration_status.to_payload()
payload.pop("status", None)
return payload
+18 -12
View File
@@ -5,7 +5,7 @@ from contextlib import suppress
from typing import Any
from urllib.parse import urlencode
from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response
from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response, status
from fastapi.responses import RedirectResponse
from authsome import audit
@@ -33,8 +33,8 @@ router = APIRouter(tags=["ui"], include_in_schema=False)
def _redirect(request: Request, url: str) -> Response:
"""Redirect normally, or via htmx full-page redirect for form compatibility."""
if request.headers.get("HX-Request") == "true":
return Response(status_code=204, headers={"HX-Redirect": url})
return RedirectResponse(url=url, status_code=303)
return Response(status_code=status.HTTP_204_NO_CONTENT, headers={"HX-Redirect": url})
return RedirectResponse(url=url, status_code=status.HTTP_303_SEE_OTHER)
class UiAuthRequiredError(Exception):
@@ -55,7 +55,9 @@ async def _resolve_ui_auth(request: Request, *, next_url: str | None = None) ->
return auth
target = _account_auth_next_url(next_url or request.query_params.get("next") or request.url.path)
raise UiAuthRequiredError(RedirectResponse(url=_account_auth_entry_url(target), status_code=303))
raise UiAuthRequiredError(
RedirectResponse(url=_account_auth_entry_url(target), status_code=status.HTTP_303_SEE_OTHER)
)
def require_ui_auth(next_url: str | None = None) -> Callable[[Request], Awaitable[CredentialService]]:
@@ -101,7 +103,7 @@ def _account_auth_next_url(value: Any) -> str:
@router.post("/auth/providers/{provider_name}/connect", include_in_schema=False)
async def connect_provider(
async def connect_provider( # noqa: PLR0913
provider_name: str,
request: Request,
background_tasks: BackgroundTasks,
@@ -232,11 +234,11 @@ async def register_account(
except ValueError as exc:
return RedirectResponse(
url=f"/login?{urlencode({'next': next_url, 'error': str(exc), 'tab': 'register'})}",
status_code=303,
status_code=status.HTTP_303_SEE_OTHER,
)
capture_event(session.email, "account_registered", {"principal_id": session.principal_id})
response = RedirectResponse(url=next_url, status_code=303)
response = RedirectResponse(url=next_url, status_code=status.HTTP_303_SEE_OTHER)
_set_ui_session_cookie(response, session.token, ui_sessions, server_base_url)
return response
@@ -258,12 +260,12 @@ async def login_account(
audit.emit_event("account.login_failed", status="failure", reason="invalid_credentials")
return RedirectResponse(
url=f"/login?{urlencode({'next': next_url, 'error': str(exc), 'tab': 'login'})}",
status_code=303,
status_code=status.HTTP_303_SEE_OTHER,
)
audit.emit_event("account.login", principal_id=session.principal_id, status="success")
capture_event(session.email, "account_logged_in", {"principal_id": session.principal_id})
response = RedirectResponse(url=next_url, status_code=303)
response = RedirectResponse(url=next_url, status_code=status.HTTP_303_SEE_OTHER)
_set_ui_session_cookie(response, session.token, ui_sessions, server_base_url)
return response
@@ -277,12 +279,16 @@ async def claim_identity_confirm(
try:
pending = ui_sessions.get_pending_claim(token)
except KeyError:
return RedirectResponse(url=f"/claim?{urlencode({'token': token, 'error': 'expired'})}", status_code=303)
return RedirectResponse(
url=f"/claim?{urlencode({'token': token, 'error': 'expired'})}", status_code=status.HTTP_303_SEE_OTHER
)
await resolve_ui_request_identity(request)
principal_id = getattr(request.state, "ui_principal_id", None)
if not principal_id:
return RedirectResponse(url=f"/login?{urlencode({'next': f'/claim?token={token}'})}", status_code=303)
return RedirectResponse(
url=f"/login?{urlencode({'next': f'/claim?token={token}'})}", status_code=status.HTTP_303_SEE_OTHER
)
pending = ui_sessions.consume_pending_claim(token)
await request.app.state.ownership_resolver.claim_identity_for_principal(
@@ -291,4 +297,4 @@ async def claim_identity_confirm(
)
request.app.state.ownership_cache.pop(pending.identity, None)
capture_event(pending.identity, "identity_claimed", {"principal_id": principal_id})
return RedirectResponse(url="/", status_code=303)
return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
+1 -4
View File
@@ -7,6 +7,7 @@ from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
import keyring
from loguru import logger
from authsome.server.config import get_server_config
@@ -85,16 +86,12 @@ class ServerSecretResolver:
def _read_keyring(self) -> str | None:
try:
import keyring
return keyring.get_password(_KEYRING_SERVICE, self._spec.keyring_username)
except Exception:
return None
def _write_keyring(self, value: str) -> bool:
try:
import keyring
keyring.set_password(_KEYRING_SERVICE, self._spec.keyring_username, value)
return True
except Exception:
+2 -3
View File
@@ -8,6 +8,7 @@ from typing import Any, Literal
from urllib.parse import urlparse
import aiosqlite
import asyncpg
from authsome.server.config import get_server_config
@@ -132,8 +133,6 @@ async def open_store_database(config: StoreDatabaseConfig) -> StoreDatabase:
await initialize_schema(database)
return database
import asyncpg
connection = await asyncpg.connect(config.dsn)
database = StoreDatabase(config=config, connection=connection)
await initialize_schema(database)
@@ -205,7 +204,7 @@ async def initialize_schema(database: StoreDatabase) -> None:
async def create_server_store(home: Path | None = None, database_url: str | None = None):
"""Create the server-owned relational Store."""
from authsome.server.store.repositories import (
from authsome.server.store.repositories import ( # noqa: PLC0415
AuditEventRegistry,
IdentityClaimRegistry,
IdentityRegistry,
+1 -1
View File
@@ -114,7 +114,7 @@ class AuditEventRegistry:
def configure_exporter(self, loop: asyncio.AbstractEventLoop | None = None):
"""Configure the process OTel logger provider to export audit logs to Store."""
global _audit_logger_provider
global _audit_logger_provider # noqa: PLW0603
exporter = _StoreAuditExporter(self, loop or asyncio.get_running_loop())
with _audit_provider_lock:
+42 -33
View File
@@ -1,11 +1,34 @@
"""Shared utility functions for authsome."""
import ctypes
import getpass
import os
import re
import shutil
import subprocess
import sys
import typing
from ctypes import wintypes
from datetime import UTC, datetime
from typing import Any
from authsome.auth.models.connection import Sensitive
from authsome.errors import AuthsomeError
SECONDS_PER_MINUTE = 60
MINUTES_PER_HOUR = 60
HOURS_PER_DAY = 24
EXIT_SUCCESS = 0
EXIT_GENERAL_ERROR = 1
EXIT_AUTHENTICATION_FAILED = 2
EXIT_CONNECTION_NOT_FOUND = 3
EXIT_PROVIDER_NOT_FOUND = 4
EXIT_CREDENTIAL_MISSING = 5
EXIT_CONNECTION_ALREADY_EXISTS = 6
EXIT_PROVIDER_ALREADY_REGISTERED = 7
EXIT_ENDPOINT_UNREACHABLE = 8
EXIT_DAEMON_UNAVAILABLE = 9
def utc_now() -> datetime:
"""Return the current UTC datetime."""
@@ -21,17 +44,16 @@ def to_rfc3339(dt: datetime) -> str:
def format_duration(total_seconds: int) -> str:
"""Return a compact readable string for a duration in seconds."""
if total_seconds < 0:
total_seconds = 0
if total_seconds < 60:
total_seconds = max(total_seconds, 0)
if total_seconds < SECONDS_PER_MINUTE:
return f"{total_seconds}s"
minutes = total_seconds // 60
if minutes < 60:
minutes = total_seconds // SECONDS_PER_MINUTE
if minutes < MINUTES_PER_HOUR:
return f"{minutes}m"
hours = minutes // 60
if hours < 24:
hours = minutes // MINUTES_PER_HOUR
if hours < HOURS_PER_DAY:
return f"{hours}h"
days = hours // 24
days = hours // HOURS_PER_DAY
return f"{days}d"
@@ -63,9 +85,6 @@ def redact(record: Any, redacted_value: str = "***REDACTED***") -> dict[str, Any
Uses get_type_hints(include_extras=True) to detect Annotated[..., Sensitive()]
fields and replaces their values with redacted_value before display.
"""
import typing
from authsome.auth.models.connection import Sensitive
data = record.model_dump(mode="json")
try:
@@ -81,14 +100,11 @@ def redact(record: Any, redacted_value: str = "***REDACTED***") -> dict[str, Any
return data
def require_os_auth(action_name: str) -> bool:
def require_os_auth(action_name: str) -> bool: # noqa: PLR0911
"""
Prompt the user for OS-level authentication (e.g., Touch ID on macOS)
before allowing a sensitive action. Returns True if authenticated, False otherwise.
"""
import subprocess
import sys
if sys.platform == "darwin":
prompt = f"Authsome requires authentication to {action_name}."
script = f'do shell script "echo authenticated" with prompt "{prompt}" with administrator privileges'
@@ -102,8 +118,6 @@ def require_os_auth(action_name: str) -> bool:
except subprocess.CalledProcessError:
return False
elif sys.platform.startswith("linux"):
import shutil
if shutil.which("pkexec"):
try:
subprocess.run(["pkexec", "true"], check=True, capture_output=True)
@@ -119,11 +133,6 @@ def require_os_auth(action_name: str) -> bool:
return False
return False
elif sys.platform == "win32":
import ctypes
import getpass
import os
from ctypes import wintypes
try:
password = getpass.getpass(f"Authsome requires authentication to {action_name}. Password: ")
if not password:
@@ -189,25 +198,25 @@ def connection_is_active(connection: dict[str, Any]) -> bool:
return datetime.now(UTC) < expiry
def format_error_code(exc: Exception) -> int:
def format_error_code(exc: Exception) -> int: # noqa: PLR0911
"""Return a numerical exit code representing the exception type."""
if exc.__class__.__name__ == "DaemonUnavailableError":
return 9
return EXIT_DAEMON_UNAVAILABLE
if not isinstance(exc, AuthsomeError | FileExistsError):
return 1
return EXIT_GENERAL_ERROR
exc_name = exc.__class__.__name__
if exc_name in ("AuthenticationFailedError", "InputCancelledError"):
return 2
return EXIT_AUTHENTICATION_FAILED
if exc_name == "ConnectionNotFoundError":
return 3
return EXIT_CONNECTION_NOT_FOUND
if exc_name in ("ProviderNotFoundError", "OperationNotAllowedError"):
return 4
return EXIT_PROVIDER_NOT_FOUND
if exc_name in ("CredentialMissingError", "TokenExpiredError", "RefreshFailedError"):
return 5
return EXIT_CREDENTIAL_MISSING
if exc_name == "ConnectionAlreadyExistsError":
return 6
return EXIT_CONNECTION_ALREADY_EXISTS
if exc_name in ("ProviderAlreadyRegisteredError", "FileExistsError"):
return 7
return EXIT_PROVIDER_ALREADY_REGISTERED
if exc_name == "EndpointUnreachableError":
return 8
return 1
return EXIT_ENDPOINT_UNREACHABLE
return EXIT_GENERAL_ERROR
+2 -2
View File
@@ -65,7 +65,7 @@ def test_read_chrome_cookies_attaches_ttl_from_cookie(monkeypatch):
yield FakeCookie(".www.linkedin.com", "li_at", "token", 1_800_000_000)
yield FakeCookie(".www.linkedin.com", "bcookie", "other", 1_800_000_000)
monkeypatch.setitem(sys.modules, "browser_cookie3", type("M", (), {"chrome": staticmethod(lambda: FakeJar())}))
monkeypatch.setitem(sys.modules, "browser_cookie3", type("M", (), {"chrome": staticmethod(FakeJar)}))
monkeypatch.setattr(mod.time, "time", lambda: 1_700_000_000)
result = mod.read_chrome_cookies([".linkedin.com"], ttl_from_cookie="li_at")
@@ -90,7 +90,7 @@ def test_read_chrome_cookies_omits_expiry_when_ttl_cookie_is_session(monkeypatch
def __iter__(self):
yield FakeCookie(".www.linkedin.com", "li_at", "token", None)
monkeypatch.setitem(sys.modules, "browser_cookie3", type("M", (), {"chrome": staticmethod(lambda: FakeJar())}))
monkeypatch.setitem(sys.modules, "browser_cookie3", type("M", (), {"chrome": staticmethod(FakeJar)}))
monkeypatch.setattr(mod.time, "time", lambda: 1_700_000_000)
result = mod.read_chrome_cookies([".linkedin.com"], ttl_from_cookie="li_at")
+2
View File
@@ -1,5 +1,7 @@
"""Tests for BrowserFlow begin/resume/refresh."""
# ruff: noqa: PLR2004
from unittest.mock import MagicMock
import pytest
+2
View File
@@ -1,5 +1,7 @@
"""Tests for authentication flows."""
# ruff: noqa: PLR2004
import pytest
from authsome.auth.flows.api_key import ApiKeyFlow
+2
View File
@@ -1,5 +1,7 @@
"""Tests for authsome data models."""
# ruff: noqa: PLR2004
from authsome.auth.models.config import current_spec_version
from authsome.auth.models.connection import (
ConnectionRecord,
+4 -3
View File
@@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, Mock
import httpx
import pytest
from fastapi import status
from authsome.cli.client import AuthsomeApiClient
from authsome.cli.config import ClientConfig, load_client_config, save_client_config
@@ -172,12 +173,12 @@ async def test_unregistered_identity_registers_on_first_use(monkeypatch, tmp_pat
calls.append((method, url))
response = Mock()
if f"/api/identities/{identity.handle}" in url and method == "GET":
response.status_code = 404
response.status_code = status.HTTP_404_NOT_FOUND
response.raise_for_status.side_effect = httpx.HTTPStatusError(
"Not Found", request=Mock(), response=Mock(status_code=404)
"Not Found", request=Mock(), response=Mock(status_code=status.HTTP_404_NOT_FOUND)
)
else:
response.status_code = 200
response.status_code = status.HTTP_200_OK
response.raise_for_status.return_value = None
if url.endswith("/api/identities/register"):
response.json.return_value = {
+2
View File
@@ -1,5 +1,7 @@
"""Tests for the `authsome doctor` command."""
# ruff: noqa: PLR2004
import json
from unittest.mock import patch
+2
View File
@@ -1,5 +1,7 @@
"""Tests for `authsome connections inspect`."""
# ruff: noqa: PLR2004
import json
from authsome.cli.main import cli
+2
View File
@@ -4,6 +4,8 @@ These functions have no I/O and require no mocking — they are tested
directly to ensure the formatting, duration, and error-code logic is correct.
"""
# ruff: noqa: PLR2004
from datetime import UTC, datetime, timedelta
import pytest
+2
View File
@@ -1,5 +1,7 @@
"""Tests for `authsome login`."""
# ruff: noqa: PLR2004
import json
from authsome.cli.main import cli
+2
View File
@@ -1,5 +1,7 @@
"""Tests for `authsome logout`."""
# ruff: noqa: PLR2004
import json
from authsome.cli.main import cli
+2
View File
@@ -1,5 +1,7 @@
"""Tests for `authsome provider revoke`."""
# ruff: noqa: PLR2004
import json
from authsome.cli.main import cli
+2
View File
@@ -1,3 +1,5 @@
# ruff: noqa: PLR2004
from pathlib import Path
import pytest
+3 -2
View File
@@ -6,6 +6,7 @@ from unittest import mock
from unittest.mock import patch
import pytest
from fastapi import status
from authsome.auth.models.connection import ConnectionRecord
from authsome.auth.models.enums import AuthType, ConnectionStatus
@@ -536,7 +537,7 @@ class TestAuthProxyAddon:
finally:
patcher.stop()
assert flow.response.status_code == 403
assert flow.response.status_code == status.HTTP_403_FORBIDDEN
assert flow.response.content == b"Forbidden by Authsome proxy policy"
auth.record_audit_event.assert_awaited_once()
event = auth.record_audit_event.await_args.args[0]
@@ -561,7 +562,7 @@ class TestAuthProxyAddon:
finally:
patcher.stop()
assert flow.response.status_code == 403
assert flow.response.status_code == status.HTTP_403_FORBIDDEN
body = flow.response.content.decode("utf-8")
assert "openai" in body
assert "authsome login openai" in body
+8 -7
View File
@@ -2,6 +2,7 @@ import json
from pathlib import Path
from urllib.parse import parse_qs, urlparse
from fastapi import status
from fastapi.testclient import TestClient
from authsome.audit import emit_event
@@ -13,7 +14,7 @@ from tests.server.test_pop_auth import _auth_header
def _claim_identity(client: TestClient, tmp_path: Path, handle: str, *, email: str) -> None:
identity = create_identity(tmp_path, handle)
response = client.post("/api/identities/register", json={"handle": identity.handle, "did": identity.did})
assert response.status_code == 200
assert response.status_code == status.HTTP_200_OK
claim_url = urlparse(response.json()["claim_url"])
token = parse_qs(claim_url.query)["token"][0]
claim_path = f"/api/claim/{token}"
@@ -22,8 +23,8 @@ def _claim_identity(client: TestClient, tmp_path: Path, handle: str, *, email: s
data={"email": email, "password": "password-1", "next": claim_path},
follow_redirects=False,
)
assert registered.status_code == 303
assert client.post(f"{claim_path}/confirm", follow_redirects=False).status_code == 303
assert registered.status_code == status.HTTP_303_SEE_OTHER
assert client.post(f"{claim_path}/confirm", follow_redirects=False).status_code == status.HTTP_303_SEE_OTHER
def test_audit_events_endpoint_returns_internal_events_for_admin(monkeypatch, tmp_path: Path) -> None:
@@ -44,7 +45,7 @@ def test_audit_events_endpoint_returns_internal_events_for_admin(monkeypatch, tm
headers=_auth_header(tmp_path, "GET", "/api/audit/events?limit=10"),
)
assert response.status_code == 200
assert response.status_code == status.HTTP_200_OK
entries = response.json()["entries"]
assert entries[0]["event"] == "login"
assert entries[0]["identity"] == "steady-wisely-boldly-0042"
@@ -72,7 +73,7 @@ def test_external_audit_post_is_enriched_from_pop_identity(monkeypatch, tmp_path
headers=_auth_header(tmp_path, "GET", "/api/audit/events?limit=10"),
)
assert posted.status_code == 200
assert posted.status_code == status.HTTP_200_OK
entries = response.json()["entries"]
assert entries[0]["event"] == "proxy_deny"
assert entries[0]["source"] == "external"
@@ -115,11 +116,11 @@ def test_admin_sees_all_audit_events_and_user_sees_only_own_principal(monkeypatc
headers=_auth_header(tmp_path, "GET", "/api/audit/events"),
)
assert admin_response.status_code == 200
assert admin_response.status_code == status.HTTP_200_OK
admin_events = {entry["event"] for entry in admin_response.json()["entries"]}
assert {"admin_event", "user_event"}.issubset(admin_events)
assert user_response.status_code == 200
assert user_response.status_code == status.HTTP_200_OK
user_entries = user_response.json()["entries"]
assert "user_event" in {entry["event"] for entry in user_entries}
assert "admin_event" not in {entry["event"] for entry in user_entries}
+7 -6
View File
@@ -3,6 +3,7 @@
import asyncio
from pathlib import Path
from fastapi import status
from fastapi.testclient import TestClient
from authsome.auth.models.enums import FlowType
@@ -41,7 +42,7 @@ def test_get_session_rejects_other_identity(monkeypatch, tmp_path: Path) -> None
with TestClient(app) as client:
owner_registration = client.post("/api/identities/register", json={"handle": owner.handle, "did": owner.did})
assert owner_registration.status_code == 200
assert owner_registration.status_code == status.HTTP_200_OK
register_and_claim_identity(client, tmp_path, stranger.handle, email="stranger@example.com")
session = asyncio.run(
client.app.state.auth_sessions.create(
@@ -63,7 +64,7 @@ def test_get_session_rejects_other_identity(monkeypatch, tmp_path: Path) -> None
),
)
assert response.status_code == 404
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json()["detail"] == "Authentication session not found"
@@ -75,12 +76,12 @@ def test_resume_session_rejects_other_identity(monkeypatch, tmp_path: Path) -> N
with TestClient(app) as client:
owner_registration = client.post("/api/identities/register", json={"handle": owner.handle, "did": owner.did})
assert owner_registration.status_code == 200
assert owner_registration.status_code == status.HTTP_200_OK
stranger_registration = client.post(
"/api/identities/register",
json={"handle": stranger.handle, "did": stranger.did},
)
assert stranger_registration.status_code == 200
assert stranger_registration.status_code == status.HTTP_200_OK
session = asyncio.run(
client.app.state.auth_sessions.create(
provider="github",
@@ -102,7 +103,7 @@ def test_resume_session_rejects_other_identity(monkeypatch, tmp_path: Path) -> N
),
)
assert response.status_code == 401
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json()["detail"] == "Proof JWT body hash does not match request"
@@ -130,5 +131,5 @@ def test_sessions_do_not_survive_app_recreation(monkeypatch, tmp_path: Path) ->
headers=_auth_header(tmp_path, "GET", f"/api/auth/sessions/{session_id}", handle=owner.handle),
)
assert response.status_code == 404
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json()["detail"] == "Authentication session not found"
+20 -18
View File
@@ -4,6 +4,7 @@ from datetime import UTC, datetime, timedelta
from pathlib import Path
from urllib.parse import parse_qs, urlparse
from fastapi import status
from fastapi.testclient import TestClient
from authsome.auth.models.connection import ConnectionRecord
@@ -14,7 +15,7 @@ from authsome.server.app import create_app
from authsome.server.credential_repository import build_store_key
def _auth_header(
def _auth_header( # noqa: PLR0913
tmp_path: Path,
method: str,
path: str,
@@ -45,18 +46,18 @@ def register_and_claim_identity(
"""Register an identity and drive the browser claim flow through to acceptance."""
identity = create_identity(tmp_path, handle)
response = client.post("/api/identities/register", json={"handle": identity.handle, "did": identity.did})
assert response.status_code == 200
assert response.status_code == status.HTTP_200_OK
claim_url = urlparse(response.json()["claim_url"])
token = parse_qs(claim_url.query)["token"][0]
claim_path = f"/api/claim/{token}"
assert client.get(claim_path).status_code == 200
assert client.get(claim_path).status_code == status.HTTP_200_OK
registered = client.post(
"/api/auth/register",
data={"email": email, "password": "password-1", "next": claim_path},
follow_redirects=False,
)
assert registered.status_code == 303
assert client.post(f"{claim_path}/confirm", follow_redirects=False).status_code == 303
assert registered.status_code == status.HTTP_303_SEE_OTHER
assert client.post(f"{claim_path}/confirm", follow_redirects=False).status_code == status.HTTP_303_SEE_OTHER
def test_whoami_requires_pop(monkeypatch, tmp_path: Path) -> None:
@@ -65,7 +66,7 @@ def test_whoami_requires_pop(monkeypatch, tmp_path: Path) -> None:
with TestClient(create_app()) as client:
response = client.get("/api/whoami")
assert response.status_code == 401
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_whoami_accepts_valid_pop_and_scopes_identity(monkeypatch, tmp_path: Path) -> None:
@@ -75,7 +76,7 @@ def test_whoami_accepts_valid_pop_and_scopes_identity(monkeypatch, tmp_path: Pat
register_and_claim_identity(client, tmp_path, "steady-wisely-boldly-0042")
response = client.get("/api/whoami", headers=_auth_header(tmp_path, "GET", "/api/whoami"))
assert response.status_code == 200
assert response.status_code == status.HTTP_200_OK
assert response.json()["identity"] == "steady-wisely-boldly-0042"
assert response.json()["principal_id"].startswith("principal_")
assert response.json()["vault_id"].startswith("vault_")
@@ -94,11 +95,11 @@ def test_health_and_ready_report_encryption_details(monkeypatch, tmp_path: Path)
health_response = client.get("/api/health")
ready_response = client.get("/api/ready", headers=_auth_header(tmp_path, "GET", "/api/ready"))
assert health_response.status_code == 200
assert health_response.status_code == status.HTTP_200_OK
assert health_response.json()["configured_encryption_mode"] == "aes-256-gcm"
assert health_response.json()["effective_encryption_source"] == "aes-256-gcm"
assert "Argon2id" in health_response.json()["encryption_backend"]
assert ready_response.status_code == 200
assert ready_response.status_code == status.HTTP_200_OK
assert ready_response.json()["configured_encryption_mode"] == "aes-256-gcm"
assert ready_response.json()["effective_encryption_source"] == "aes-256-gcm"
assert "Argon2id" in ready_response.json()["encryption_backend"]
@@ -111,7 +112,7 @@ def test_registration_requires_claim(monkeypatch, tmp_path: Path) -> None:
with TestClient(create_app()) as client:
response = client.post("/api/identities/register", json={"handle": identity.handle, "did": identity.did})
assert response.status_code == 200
assert response.status_code == status.HTTP_200_OK
assert response.json()["registration_status"] == "claim_required"
assert "/claim?" in response.json()["claim_url"]
@@ -124,7 +125,7 @@ def test_whoami_rejects_wrong_path_claim(monkeypatch, tmp_path: Path) -> None:
client.post("/api/identities/register", json={"handle": identity.handle, "did": identity.did})
response = client.get("/api/whoami", headers=_auth_header(tmp_path, "GET", "/api/connections"))
assert response.status_code == 401
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_whoami_rejects_unknown_subject(monkeypatch, tmp_path: Path) -> None:
@@ -133,7 +134,7 @@ def test_whoami_rejects_unknown_subject(monkeypatch, tmp_path: Path) -> None:
with TestClient(create_app()) as client:
response = client.get("/api/whoami", headers=_auth_header(tmp_path, "GET", "/api/whoami"))
assert response.status_code == 401
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_whoami_rejects_registered_handle_with_wrong_issuer(monkeypatch, tmp_path: Path) -> None:
@@ -155,7 +156,7 @@ def test_whoami_rejects_registered_handle_with_wrong_issuer(monkeypatch, tmp_pat
),
)
assert response.status_code == 401
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_identity_registration_rejects_duplicate_handle_different_did(monkeypatch, tmp_path: Path) -> None:
@@ -165,11 +166,12 @@ def test_identity_registration_rejects_duplicate_handle_different_did(monkeypatc
with TestClient(create_app()) as client:
assert (
client.post("/api/identities/register", json={"handle": first.handle, "did": first.did}).status_code == 200
client.post("/api/identities/register", json={"handle": first.handle, "did": first.did}).status_code
== status.HTTP_200_OK
)
response = client.post("/api/identities/register", json={"handle": first.handle, "did": second.did})
assert response.status_code == 409
assert response.status_code == status.HTTP_409_CONFLICT
def test_identity_registration_rejects_duplicate_did_different_handle(monkeypatch, tmp_path: Path) -> None:
@@ -179,14 +181,14 @@ def test_identity_registration_rejects_duplicate_did_different_handle(monkeypatc
with TestClient(create_app()) as client:
assert (
client.post("/api/identities/register", json={"handle": identity.handle, "did": identity.did}).status_code
== 200
== status.HTTP_200_OK
)
response = client.post(
"/api/identities/register",
json={"handle": "rapid-brightly-firmly-0007", "did": identity.did},
)
assert response.status_code == 409
assert response.status_code == status.HTTP_409_CONFLICT
def test_ready_uses_active_identity_connections_for_warning_check(monkeypatch, tmp_path: Path) -> None:
@@ -214,6 +216,6 @@ def test_ready_uses_active_identity_connections_for_warning_check(monkeypatch, t
response = client.get("/api/ready", headers=_auth_header(tmp_path, "GET", "/api/ready"))
assert response.status_code == 200
assert response.status_code == status.HTTP_200_OK
assert response.json()["checks"]["connections"] == "ok"
assert "no active provider connections found" not in response.json()["warnings"]
@@ -2,6 +2,7 @@ import json
from pathlib import Path
from urllib.parse import parse_qs, urlparse
from fastapi import status
from fastapi.testclient import TestClient
from authsome.cli.identity import create_identity
@@ -12,21 +13,21 @@ from tests.server.test_pop_auth import _auth_header
def _register_identity(client: TestClient, tmp_path: Path, handle: str, *, email: str = "dev@example.com") -> None:
identity = create_identity(tmp_path, handle)
response = client.post("/api/identities/register", json={"handle": identity.handle, "did": identity.did})
assert response.status_code == 200
assert response.status_code == status.HTTP_200_OK
claim_url = response.json().get("claim_url")
if claim_url:
parsed = urlparse(claim_url)
token = parse_qs(parsed.query)["token"][0]
claim_path = f"/api/claim/{token}"
assert client.get(claim_path).status_code == 200
assert client.get(claim_path).status_code == status.HTTP_200_OK
registered = client.post(
"/api/auth/register",
data={"email": email, "password": "password-1", "next": claim_path},
follow_redirects=False,
)
assert registered.status_code == 303
assert registered.status_code == status.HTTP_303_SEE_OTHER
claimed = client.post(f"{claim_path}/confirm", follow_redirects=False)
assert claimed.status_code == 303
assert claimed.status_code == status.HTTP_303_SEE_OTHER
def _register_admin_then_user(client: TestClient, tmp_path: Path, user_handle: str) -> None:
@@ -44,7 +45,7 @@ def test_non_admin_revoke_is_rejected(monkeypatch, tmp_path: Path) -> None:
headers=_auth_header(tmp_path, "POST", "/api/connections/github/revoke"),
)
assert response.status_code == 403
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json()["detail"] == "Admin role required"
@@ -58,7 +59,7 @@ def test_non_admin_remove_is_rejected(monkeypatch, tmp_path: Path) -> None:
headers=_auth_header(tmp_path, "DELETE", "/api/providers/github"),
)
assert response.status_code == 403
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json()["detail"] == "Admin role required"
@@ -86,7 +87,7 @@ def test_non_admin_register_provider_is_rejected(monkeypatch, tmp_path: Path) ->
},
)
assert response.status_code == 403
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json()["detail"] == "Admin role required"
@@ -114,5 +115,5 @@ def test_first_principal_admin_can_register_provider(monkeypatch, tmp_path: Path
},
)
assert response.status_code == 200
assert response.status_code == status.HTTP_200_OK
assert response.json()["status"] == "ok"
Generated
+41 -38
View File
@@ -688,11 +688,11 @@ wheels = [
[[package]]
name = "filelock"
version = "3.29.0"
version = "3.29.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" }
sdist = { url = "https://files.pythonhosted.org/packages/1f/f9/f38573ed5844586db374d085911740a501ccfa373b455fc9413f09f85237/filelock-3.29.1.tar.gz", hash = "sha256:d97e6b1b9757569626c58caa07dc4beb1613f4a2938b1e8cc81afca398906c9e", size = 59335, upload-time = "2026-06-03T15:19:04.053Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" },
{ url = "https://files.pythonhosted.org/packages/4c/a0/614c5fe402fd88951df45f4dda2fa3b4e17a99ecd92340771929169b3b95/filelock-3.29.1-py3-none-any.whl", hash = "sha256:85199dfd706869641b72b2e8955d5416a4b2b7dc4b0e8e6d97b4cc1299a6983b", size = 40750, upload-time = "2026-06-03T15:19:02.959Z" },
]
[[package]]
@@ -1214,7 +1214,7 @@ wheels = [
[[package]]
name = "posthog"
version = "7.16.3"
version = "7.17.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backoff" },
@@ -1222,9 +1222,9 @@ dependencies = [
{ name = "requests" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/20/d4/6e1c24823c515683cb6f74bd1dcc3fbe55f60a27743c1694bdc61c5db5d0/posthog-7.16.3.tar.gz", hash = "sha256:ff8972813c836ae4fcb634b499cf06d643bc3c4f49931218fe142e7aa8a39810", size = 226535, upload-time = "2026-06-01T13:24:07.781Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5d/53/9abb7c2ec8acfcafd488a379f5937d248bf290f9fe8c0a2edc023063b7e9/posthog-7.17.0.tar.gz", hash = "sha256:25bcd488a2b2359b0ae969ffcba75ecb5fd0287ad3f32fffe645659d543dbf17", size = 229049, upload-time = "2026-06-03T15:14:41.202Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/0b/562afe6f037ab3dde06d2bbe33f1de9f8516ac22f882ad263e4e0bfb665b/posthog-7.16.3-py3-none-any.whl", hash = "sha256:f0cbaf25ac06211b87c0a43500673fa2d8de86eb1edb75b8bf688f1b884878ce", size = 264300, upload-time = "2026-06-01T13:24:06.074Z" },
{ url = "https://files.pythonhosted.org/packages/93/fe/49358d2a9ebde6538ace96785e5b59c4df302ab0b72fef6010625cb77c06/posthog-7.17.0-py3-none-any.whl", hash = "sha256:b2735701effba2f8f4ccf58b0b49fbaaf7d4d0544fb6d6b7547cd7ccde5d4f43", size = 267430, upload-time = "2026-06-03T15:14:39.432Z" },
]
[[package]]
@@ -1546,24 +1546,27 @@ wheels = [
[[package]]
name = "python-multipart"
version = "0.0.30"
version = "0.0.31"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4b/82/c8cd43a6e0719bf5a3b034f6726dd701f75829c08944c83d4b95d02ed0e8/python_multipart-0.0.30.tar.gz", hash = "sha256:0edfe0475c1f46ddd3ff7785a626f6118af32bdcf359bb21260367313bb32118", size = 46316, upload-time = "2026-05-31T19:24:55.198Z" }
sdist = { url = "https://files.pythonhosted.org/packages/64/7e/9b35ad8f3d9ca680f7c87a88f19612fdd8da9796c4d3b46e560ac79dcc4a/python_multipart-0.0.31.tar.gz", hash = "sha256:fc631183bb13e56db3158a4909908dfb2e23565286744e798241e63750e5d680", size = 46689, upload-time = "2026-06-04T08:27:49.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1c/fd/0318007beb234790993d3ec5afd051d1dbceb733e81e3afe2b981ece3f37/python_multipart-0.0.30-py3-none-any.whl", hash = "sha256:830964def8c90607ac5daa00514e3987815865713ade8d20febc9177ac0c3c5b", size = 29730, upload-time = "2026-05-31T19:24:53.814Z" },
{ url = "https://files.pythonhosted.org/packages/5e/1e/7f7f299527a5a8ad90acd5f2f78dfa6c8495c6301a3205106ea68a84de96/python_multipart-0.0.31-py3-none-any.whl", hash = "sha256:8408153d68a9773291fc1da39a8b85a50044bddbabd2dd72e9229776b7b15e28", size = 29996, upload-time = "2026-06-04T08:27:47.804Z" },
]
[[package]]
name = "pywin32"
version = "311"
version = "312"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" },
{ url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" },
{ url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" },
{ url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" },
{ url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" },
{ url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
{ url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" },
{ url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" },
{ url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" },
{ url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" },
{ url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" },
{ url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" },
{ url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" },
{ url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" },
{ url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" },
]
[[package]]
@@ -1738,27 +1741,27 @@ wheels = [
[[package]]
name = "ty"
version = "0.0.42"
version = "0.0.43"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/91/5b5ec4ed8721c18be8d9611778d7c07723cd755676f03b41bf0ea0caa5d3/ty-0.0.42.tar.gz", hash = "sha256:70f5553ac678fc63558d4d77b08a18a68a228f44be2a2fe1afc3f5988db662e7", size = 5769116, upload-time = "2026-06-01T19:40:32.869Z" }
sdist = { url = "https://files.pythonhosted.org/packages/0d/37/4ec04de0659b93be37d956dfceca13b1ecab9c959f28d8a1d5e514603f36/ty-0.0.43.tar.gz", hash = "sha256:ea4cff50548f2a1877e848d3abe9e293cde8ab94757a7eb93fc0d4013f98be8e", size = 5798429, upload-time = "2026-06-04T00:52:10.013Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/7c/2df5136ad7c0db69a3973b0b19da8f52bfacdc453c7dffc832d1bf7d23ff/ty-0.0.42-py3-none-linux_armv6l.whl", hash = "sha256:c08a0066610c13627b7d7ad758adb96ca99685791e641eb26837e20803851c53", size = 11544141, upload-time = "2026-06-01T19:40:41.065Z" },
{ url = "https://files.pythonhosted.org/packages/0a/82/96cf406d39d8976e825361a27e332224445812793060ac9506d8a5d32b39/ty-0.0.42-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3e944ee4e3d5cdaf70e4ea87f9dd474cc3db612837b50a3ce57afa8da400ecc2", size = 11283538, upload-time = "2026-06-01T19:40:26.758Z" },
{ url = "https://files.pythonhosted.org/packages/dc/fe/813b60b9332df835c16c05859ff5aac1896593d01b638ba0e461ede415ac/ty-0.0.42-py3-none-macosx_11_0_arm64.whl", hash = "sha256:603085306e4aac2ce592b39119a4b49ebf8b780cd394e2cfc7dbf3fd8228f954", size = 10711874, upload-time = "2026-06-01T19:40:28.77Z" },
{ url = "https://files.pythonhosted.org/packages/07/64/1f609265be0302ce0f51aa03a27636d018947a76100ff1405258d8445e6c/ty-0.0.42-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a58f17834d7f078c49326a01111a5aac16c979a774b98cdfd8e2350068316676", size = 11213021, upload-time = "2026-06-01T19:40:45.01Z" },
{ url = "https://files.pythonhosted.org/packages/88/c0/f147b2fde7cd01b5f77682937e85a5cdfe35f48b3f1d0f41021024cbd927/ty-0.0.42-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e6ed1027f313202c5c74e376007d1eb5d214494299beb0ea047078b8ad307d40", size = 11321604, upload-time = "2026-06-01T19:40:55.164Z" },
{ url = "https://files.pythonhosted.org/packages/a9/72/74a5e68a9bd194681f15c4aac7a0dfab378e76e7a107e8ecd93971a22377/ty-0.0.42-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:063838d2360c1d2c065b45ca76a56ecd6df07fff6570813e74183236559e16d9", size = 11802178, upload-time = "2026-06-01T19:40:19.558Z" },
{ url = "https://files.pythonhosted.org/packages/3c/9f/06a31dc9cc91faa2cb8a4bf2f0ba5f7a9a96a4828fb8434338682954ca86/ty-0.0.42-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c3f9ed508dfae4cbc943d7766324dd9c57ac8302c8543505fc29cae8ed425fe9", size = 12358436, upload-time = "2026-06-01T19:40:48.968Z" },
{ url = "https://files.pythonhosted.org/packages/06/33/a5bb1afcb671e0b9197f007264682b22f1063bf0e83c151eeaf9958f9047/ty-0.0.42-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fe1eb7d98472ac56ac19fec51c6ed8fe56d86ea0d232a11a127e8c62c882a66", size = 11997849, upload-time = "2026-06-01T19:40:42.951Z" },
{ url = "https://files.pythonhosted.org/packages/ee/8d/560e4ec4c2f69e68fa094bb93cd19b5eb92c9732d2e0f5e7cc3accde84c4/ty-0.0.42-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de75f9e78bfa81209f2f297528758977cfd4518ba35ef45a0acb516c892a27a5", size = 11869087, upload-time = "2026-06-01T19:40:24.38Z" },
{ url = "https://files.pythonhosted.org/packages/b2/dd/3794db15199c03eda60961690046a56c4fbf5d8ef073c82fe2402c851b8c/ty-0.0.42-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c63281f2f1d4df339117fcd4a6dfe17cb999f84eafe707b30e9ebbe26f0bb54a", size = 12059000, upload-time = "2026-06-01T19:40:34.982Z" },
{ url = "https://files.pythonhosted.org/packages/98/9a/02b61cc65ecbd90f18bd02178845c5b756c78fb92643571e77df52c6eed8/ty-0.0.42-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:19e3856477f25255f772851fa7f16f5356c4e1927324d074d49c7bc9a9b211e1", size = 11195698, upload-time = "2026-06-01T19:40:37.109Z" },
{ url = "https://files.pythonhosted.org/packages/7b/d6/3927335c956b06a806269dcd2e5b46bc4284b0a95ecc6b0246094a1de28c/ty-0.0.42-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2f0f4acac9028264cee5ea0b88229df0b9b2586fc917dbadb6ee35a0e99e8b06", size = 11353487, upload-time = "2026-06-01T19:40:47.035Z" },
{ url = "https://files.pythonhosted.org/packages/d6/87/79e7ae4f5f9fb3bea5f3cfac2b4f8c60e905f13962e3c0d97f8b51a5bff6/ty-0.0.42-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ae244c84e30fdf2bb1a3cbf2b973da8aa535e57c701f12db44b2939604586c04", size = 11463474, upload-time = "2026-06-01T19:40:30.812Z" },
{ url = "https://files.pythonhosted.org/packages/49/f9/73305ead1b3ccc3d79c04258877db2cd7908c6a6c2060d4e598deca384d2/ty-0.0.42-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2430434f4a52bec0da552ff6a061dcc1c5d11973259248679a1146d776c12f37", size = 11961710, upload-time = "2026-06-01T19:40:39.056Z" },
{ url = "https://files.pythonhosted.org/packages/e1/04/52b7325dad8d1a86653f90240208f3e3657bed5e3c8144eed367a0f736b0/ty-0.0.42-py3-none-win32.whl", hash = "sha256:984a55c2fe63b40dac03f5a144b99033c7ed720eb7611787e3f0bd49af8dcf12", size = 10783897, upload-time = "2026-06-01T19:40:22.189Z" },
{ url = "https://files.pythonhosted.org/packages/94/d2/3d2d61255c76c0843766f00b39290115c23cc1cd4fcb0471d84a48f482f1/ty-0.0.42-py3-none-win_amd64.whl", hash = "sha256:f7afd81b10b377d9d4ce6aad355a4f47fd37d47f443118c01ca6e79d46fe6608", size = 11878640, upload-time = "2026-06-01T19:40:50.903Z" },
{ url = "https://files.pythonhosted.org/packages/b4/91/1eb0c1e3d558707ead7424f8bfd89b58f42e576714cbed7ad46dcceef34a/ty-0.0.42-py3-none-win_arm64.whl", hash = "sha256:4068c24b0b264fc9f1901e06b97988a041fcaa36c90f18d7747f05124701c7b3", size = 11202335, upload-time = "2026-06-01T19:40:53.155Z" },
{ url = "https://files.pythonhosted.org/packages/db/74/1916026a78f20019a2f03adbd6fb4430ddb7ce1e52c2e17a90856a6d192e/ty-0.0.43-py3-none-linux_armv6l.whl", hash = "sha256:3bf70f5446480562bf6c9f639df4b5cb60716b8f8d1a6b8e5811d5c7eccd8bf2", size = 11598153, upload-time = "2026-06-04T00:52:20.646Z" },
{ url = "https://files.pythonhosted.org/packages/b9/af/58bb0089d2635216c8fa6612dd486a3f986d0ab1c46a41527ab95e57f0e3/ty-0.0.43-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7184741f8b15425a1bc64b950ad005cb353573288ac0e8a04f5481ceb3832596", size = 11357811, upload-time = "2026-06-04T00:52:24.683Z" },
{ url = "https://files.pythonhosted.org/packages/d6/9c/32c6b14f3feddf87b59c7a50709e2b3da408258f2f583f05575f77bc8f7b/ty-0.0.43-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8c306379ca9a35f6ae5270fe9bda7af4b46d91822725a2586d78c8b9b5493b62", size = 10772024, upload-time = "2026-06-04T00:52:14.312Z" },
{ url = "https://files.pythonhosted.org/packages/09/fa/98aa4a74bd00cd5efc424923cd1daffbf1e40a0338041cafb203379d746f/ty-0.0.43-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d624b884c9c1fd244ad2a5f026364e7162a22b3f537025941ada2e363e676414", size = 11291034, upload-time = "2026-06-04T00:52:37.249Z" },
{ url = "https://files.pythonhosted.org/packages/b5/db/4de086c38ce96dcada2bd451f43171d2c237f96d8ed19a1ea8fe51bb8ef4/ty-0.0.43-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:281fc4c00fbc196045141faa085055bddc58846b04a2800204701415a1b9c6aa", size = 11364724, upload-time = "2026-06-04T00:52:33.138Z" },
{ url = "https://files.pythonhosted.org/packages/b0/d3/e3cd8e3233a6fd8362a49aa025b79e9f40151a2a86d811ace154c6eb7445/ty-0.0.43-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f57d6cc28de89024b48d1788e4758c05299d5749d4a51c02e71ac655ec23d9a5", size = 11890555, upload-time = "2026-06-04T00:52:22.711Z" },
{ url = "https://files.pythonhosted.org/packages/80/7b/6f46d444e8241606bbde098df3dca93f2ec0b834a42055db85ee7d33646f/ty-0.0.43-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0a1d6ad6c5e7792c7eac0a01e550f2c2004462e01a64a91ea1636aba6fef6e71", size = 12450968, upload-time = "2026-06-04T00:52:28.94Z" },
{ url = "https://files.pythonhosted.org/packages/4a/e1/79fbe51f2e4b9d8347f2013cd7ed0b63f3b499038c02dc0357e9b28a3a47/ty-0.0.43-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:66d474395d7635fb618bdbb58b4e3360259a2056d0a5621b82754b9da2cd8a04", size = 12064187, upload-time = "2026-06-04T00:52:12.039Z" },
{ url = "https://files.pythonhosted.org/packages/9b/3f/c758a3a8df5b90d331f2b60c8f16021ee64d75e78f99d67cc4efc9bf5f4b/ty-0.0.43-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2663a0003a8b60fb98db7f6f6e673df80b21d0fe3a9868a26fb06b4e049b6fc4", size = 11943208, upload-time = "2026-06-04T00:52:31.14Z" },
{ url = "https://files.pythonhosted.org/packages/54/5f/f516442749cf1b45ca6720a5d41df2738a486ed9ace774c03d515db89084/ty-0.0.43-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:d5a6c352d374d889189d5ec82b54b26a5885f769f7b7787f7f875500dcb8673e", size = 12143572, upload-time = "2026-06-04T00:52:18.457Z" },
{ url = "https://files.pythonhosted.org/packages/b7/bf/0d83c7f43bf4c10f3678bfe7d938e51c445298c7b923f155c5204730c2df/ty-0.0.43-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e7dbbeedfad3ca250d74fcc355fa9ab6b38d2a17f22d6304f615716939dbbb27", size = 11279355, upload-time = "2026-06-04T00:52:26.726Z" },
{ url = "https://files.pythonhosted.org/packages/3e/de/a6c978bef6d9e949f79f4782d9e4ee4df0893713e73b055d84c1a5116b9a/ty-0.0.43-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:24b18a0273ee46154996cfcfa27438f851f440c925587ec200df6f98dffe67d3", size = 11408412, upload-time = "2026-06-04T00:52:35.282Z" },
{ url = "https://files.pythonhosted.org/packages/ec/b1/d13857c23867f0f76b92e38e5841c64ca5e76dc5d4bf27f52cb81d8ab685/ty-0.0.43-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2ef681951520d692b7e9c0b5e56aacf4f98ccae47cf6ffccaf2c7b6b33dc226e", size = 11541709, upload-time = "2026-06-04T00:52:16.451Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f1/cd6afc6f6a687e238bf5e12189f7920e81a0bdef6c3dba4c784ef140f7d9/ty-0.0.43-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2af105de7437143aa4676b28016b5bee661aaaa4eff52be5867fb25119641ceb", size = 12041266, upload-time = "2026-06-04T00:52:43.541Z" },
{ url = "https://files.pythonhosted.org/packages/bd/ba/51ca7c3335da2b8d0a3e477fa4986be9f4a53b05bfab862967d8d2e6ca60/ty-0.0.43-py3-none-win32.whl", hash = "sha256:e4773115b0d6486ee30f1657fc8bdffe7e3a3f5300ab77ef2495da6e83e4694f", size = 10858724, upload-time = "2026-06-04T00:52:07.843Z" },
{ url = "https://files.pythonhosted.org/packages/9f/29/5d80453e5f7c520145fa058851da87230dbd7ca761a7675447a9fe504e0b/ty-0.0.43-py3-none-win_amd64.whl", hash = "sha256:48d3545094a4ae6395492c7e6ac90550fce969e0ed2815fbf8c5da9756676b7d", size = 11976157, upload-time = "2026-06-04T00:52:41.438Z" },
{ url = "https://files.pythonhosted.org/packages/dc/ed/befe5a543e5b95e754ed38ee95239e44efda9bc5f578db4ac1bc8dd758d6/ty-0.0.43-py3-none-win_arm64.whl", hash = "sha256:740ca33d7f75f655a4e7d475bc42dfb825c13219bb073fad30fcc04d35790c74", size = 11308680, upload-time = "2026-06-04T00:52:39.233Z" },
]
[[package]]
@@ -1805,15 +1808,15 @@ wheels = [
[[package]]
name = "uvicorn"
version = "0.48.0"
version = "0.49.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e6/bf/f6544ba992ddb9a6077343a576f9844f7f8f06ab819aefd00206e9255f18/uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37", size = 91074, upload-time = "2026-05-24T12:08:41.925Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410, upload-time = "2026-05-24T12:08:40.258Z" },
{ url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" },
]
[[package]]